@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
package/dist/core/index.mjs
CHANGED
|
@@ -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/core/interfaces/adminDB.ts
|
|
17
7
|
var init_adminDB = __esm({
|
|
@@ -166,6 +156,9 @@ var init_pouchdb_setup = __esm({
|
|
|
166
156
|
"use strict";
|
|
167
157
|
PouchDB.plugin(PouchDBFind);
|
|
168
158
|
PouchDB.plugin(PouchDBAuth);
|
|
159
|
+
if (typeof PouchDB.debug !== "undefined") {
|
|
160
|
+
PouchDB.debug.disable();
|
|
161
|
+
}
|
|
169
162
|
PouchDB.defaults({
|
|
170
163
|
// ajax: {
|
|
171
164
|
// timeout: 60000,
|
|
@@ -175,14 +168,6 @@ var init_pouchdb_setup = __esm({
|
|
|
175
168
|
}
|
|
176
169
|
});
|
|
177
170
|
|
|
178
|
-
// src/util/tuiLogger.ts
|
|
179
|
-
var init_tuiLogger = __esm({
|
|
180
|
-
"src/util/tuiLogger.ts"() {
|
|
181
|
-
"use strict";
|
|
182
|
-
init_dataDirectory();
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
|
|
186
171
|
// src/util/dataDirectory.ts
|
|
187
172
|
import * as path from "path";
|
|
188
173
|
import * as os from "os";
|
|
@@ -199,7 +184,7 @@ function getDbPath(dbName) {
|
|
|
199
184
|
var init_dataDirectory = __esm({
|
|
200
185
|
"src/util/dataDirectory.ts"() {
|
|
201
186
|
"use strict";
|
|
202
|
-
|
|
187
|
+
init_logger();
|
|
203
188
|
init_factory();
|
|
204
189
|
}
|
|
205
190
|
});
|
|
@@ -688,195 +673,187 @@ var init_courseLookupDB = __esm({
|
|
|
688
673
|
}
|
|
689
674
|
});
|
|
690
675
|
|
|
691
|
-
// src/core/navigators/
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
676
|
+
// src/core/navigators/index.ts
|
|
677
|
+
function getCardOrigin(card) {
|
|
678
|
+
if (card.provenance.length === 0) {
|
|
679
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
680
|
+
}
|
|
681
|
+
const firstEntry = card.provenance[0];
|
|
682
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
683
|
+
if (reason.includes("failed")) {
|
|
684
|
+
return "failed";
|
|
685
|
+
}
|
|
686
|
+
if (reason.includes("review")) {
|
|
687
|
+
return "review";
|
|
688
|
+
}
|
|
689
|
+
return "new";
|
|
690
|
+
}
|
|
691
|
+
function isGenerator(impl) {
|
|
692
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
693
|
+
}
|
|
694
|
+
function isFilter(impl) {
|
|
695
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
696
|
+
}
|
|
697
|
+
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
698
|
+
var init_navigators = __esm({
|
|
699
|
+
"src/core/navigators/index.ts"() {
|
|
700
700
|
"use strict";
|
|
701
|
-
init_navigators();
|
|
702
701
|
init_logger();
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
702
|
+
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
703
|
+
Navigators2["ELO"] = "elo";
|
|
704
|
+
Navigators2["SRS"] = "srs";
|
|
705
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
706
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
707
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
708
|
+
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
709
|
+
return Navigators2;
|
|
710
|
+
})(Navigators || {});
|
|
711
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
712
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
713
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
714
|
+
return NavigatorRole2;
|
|
715
|
+
})(NavigatorRole || {});
|
|
716
|
+
NavigatorRoles = {
|
|
717
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
718
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
719
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
720
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
721
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
722
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
723
|
+
};
|
|
724
|
+
ContentNavigator = class {
|
|
725
|
+
/** User interface for this navigation session */
|
|
726
|
+
user;
|
|
727
|
+
/** Course interface for this navigation session */
|
|
728
|
+
course;
|
|
729
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
730
|
+
strategyName;
|
|
731
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
732
|
+
strategyId;
|
|
727
733
|
/**
|
|
728
|
-
*
|
|
734
|
+
* Constructor for standard navigators.
|
|
735
|
+
* Call this from subclass constructors to initialize common fields.
|
|
729
736
|
*
|
|
730
|
-
*
|
|
737
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
738
|
+
* user/course fields directly if needed.
|
|
731
739
|
*/
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
)
|
|
736
|
-
|
|
740
|
+
constructor(user, course, strategyData) {
|
|
741
|
+
this.user = user;
|
|
742
|
+
this.course = course;
|
|
743
|
+
if (strategyData) {
|
|
744
|
+
this.strategyName = strategyData.name;
|
|
745
|
+
this.strategyId = strategyData._id;
|
|
746
|
+
}
|
|
737
747
|
}
|
|
748
|
+
// ============================================================================
|
|
749
|
+
// STRATEGY STATE HELPERS
|
|
750
|
+
// ============================================================================
|
|
751
|
+
//
|
|
752
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
753
|
+
// learned patterns, temporal tracking) in the user database.
|
|
754
|
+
//
|
|
755
|
+
// ============================================================================
|
|
738
756
|
/**
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
* Cards appearing in multiple generators receive a score boost.
|
|
742
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
743
|
-
*
|
|
744
|
-
* This method supports both the legacy signature (limit only) and the
|
|
745
|
-
* CardGenerator interface signature (limit, context).
|
|
757
|
+
* Unique key identifying this strategy for state storage.
|
|
746
758
|
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
759
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
760
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
761
|
+
* need separate state storage.
|
|
749
762
|
*/
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
753
|
-
);
|
|
754
|
-
const byCardId = /* @__PURE__ */ new Map();
|
|
755
|
-
for (const cards of results) {
|
|
756
|
-
for (const card of cards) {
|
|
757
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
758
|
-
existing.push(card);
|
|
759
|
-
byCardId.set(card.cardId, existing);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
const merged = [];
|
|
763
|
-
for (const [, cards] of byCardId) {
|
|
764
|
-
const aggregatedScore = this.aggregateScores(cards);
|
|
765
|
-
const finalScore = Math.min(1, aggregatedScore);
|
|
766
|
-
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
767
|
-
const initialScore = cards[0].score;
|
|
768
|
-
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
769
|
-
const reason = this.buildAggregationReason(cards, finalScore);
|
|
770
|
-
merged.push({
|
|
771
|
-
...cards[0],
|
|
772
|
-
score: finalScore,
|
|
773
|
-
provenance: [
|
|
774
|
-
...mergedProvenance,
|
|
775
|
-
{
|
|
776
|
-
strategy: "composite",
|
|
777
|
-
strategyName: "Composite Generator",
|
|
778
|
-
strategyId: "COMPOSITE_GENERATOR",
|
|
779
|
-
action,
|
|
780
|
-
score: finalScore,
|
|
781
|
-
reason
|
|
782
|
-
}
|
|
783
|
-
]
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
763
|
+
get strategyKey() {
|
|
764
|
+
return this.constructor.name;
|
|
787
765
|
}
|
|
788
766
|
/**
|
|
789
|
-
*
|
|
767
|
+
* Get this strategy's persisted state for the current course.
|
|
768
|
+
*
|
|
769
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
770
|
+
* @throws Error if user or course is not initialized
|
|
790
771
|
*/
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
798
|
-
switch (this.aggregationMode) {
|
|
799
|
-
case "max" /* MAX */:
|
|
800
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
801
|
-
case "average" /* AVERAGE */:
|
|
802
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
803
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
804
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
805
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
806
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
807
|
-
}
|
|
808
|
-
default:
|
|
809
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
772
|
+
async getStrategyState() {
|
|
773
|
+
if (!this.user || !this.course) {
|
|
774
|
+
throw new Error(
|
|
775
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
776
|
+
);
|
|
810
777
|
}
|
|
778
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
811
779
|
}
|
|
812
780
|
/**
|
|
813
|
-
*
|
|
781
|
+
* Persist this strategy's state for the current course.
|
|
782
|
+
*
|
|
783
|
+
* @param data - The strategy's data payload to store
|
|
784
|
+
* @throws Error if user or course is not initialized
|
|
814
785
|
*/
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
case "average" /* AVERAGE */:
|
|
821
|
-
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
822
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
823
|
-
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
824
|
-
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
825
|
-
return avg * frequencyBoost;
|
|
826
|
-
}
|
|
827
|
-
default:
|
|
828
|
-
return scores[0];
|
|
786
|
+
async putStrategyState(data) {
|
|
787
|
+
if (!this.user || !this.course) {
|
|
788
|
+
throw new Error(
|
|
789
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
790
|
+
);
|
|
829
791
|
}
|
|
792
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
830
793
|
}
|
|
831
794
|
/**
|
|
832
|
-
*
|
|
795
|
+
* Factory method to create navigator instances dynamically.
|
|
796
|
+
*
|
|
797
|
+
* @param user - User interface
|
|
798
|
+
* @param course - Course interface
|
|
799
|
+
* @param strategyData - Strategy configuration document
|
|
800
|
+
* @returns the runtime object used to steer a study session.
|
|
833
801
|
*/
|
|
834
|
-
async
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
802
|
+
static async create(user, course, strategyData) {
|
|
803
|
+
const implementingClass = strategyData.implementingClass;
|
|
804
|
+
let NavigatorImpl;
|
|
805
|
+
const variations = [".ts", ".js", ""];
|
|
806
|
+
const dirs = ["filters", "generators"];
|
|
807
|
+
for (const ext of variations) {
|
|
808
|
+
for (const dir of dirs) {
|
|
809
|
+
const loadFrom = `./${dir}/${implementingClass}${ext}`;
|
|
810
|
+
try {
|
|
811
|
+
const module = await import(loadFrom);
|
|
812
|
+
NavigatorImpl = module.default;
|
|
813
|
+
break;
|
|
814
|
+
} catch (e) {
|
|
815
|
+
logger.debug(`Failed to load extension from ${loadFrom}:`, e);
|
|
846
816
|
}
|
|
847
817
|
}
|
|
848
818
|
}
|
|
849
|
-
|
|
819
|
+
if (!NavigatorImpl) {
|
|
820
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
821
|
+
}
|
|
822
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
850
823
|
}
|
|
851
824
|
/**
|
|
852
|
-
* Get
|
|
825
|
+
* Get cards with suitability scores and provenance trails.
|
|
826
|
+
*
|
|
827
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
828
|
+
*
|
|
829
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
830
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
831
|
+
* documenting how strategies contributed to the final score.
|
|
832
|
+
*
|
|
833
|
+
* ## Implementation Required
|
|
834
|
+
* All navigation strategies MUST override this method. The base class does
|
|
835
|
+
* not provide a default implementation.
|
|
836
|
+
*
|
|
837
|
+
* ## For Generators
|
|
838
|
+
* Override this method to generate candidates and compute scores based on
|
|
839
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
840
|
+
* initial provenance entry with action='generated'.
|
|
841
|
+
*
|
|
842
|
+
* ## For Filters
|
|
843
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
844
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
845
|
+
*
|
|
846
|
+
* @param limit - Maximum cards to return
|
|
847
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
853
848
|
*/
|
|
854
|
-
async
|
|
855
|
-
|
|
856
|
-
(g) => g instanceof ContentNavigator
|
|
857
|
-
);
|
|
858
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
859
|
-
const seen = /* @__PURE__ */ new Set();
|
|
860
|
-
const merged = [];
|
|
861
|
-
for (const reviews of results) {
|
|
862
|
-
for (const review of reviews) {
|
|
863
|
-
if (!seen.has(review.cardID)) {
|
|
864
|
-
seen.add(review.cardID);
|
|
865
|
-
merged.push(review);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
return merged;
|
|
849
|
+
async getWeightedCards(_limit) {
|
|
850
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
870
851
|
}
|
|
871
852
|
};
|
|
872
853
|
}
|
|
873
854
|
});
|
|
874
855
|
|
|
875
856
|
// src/core/navigators/Pipeline.ts
|
|
876
|
-
var Pipeline_exports = {};
|
|
877
|
-
__export(Pipeline_exports, {
|
|
878
|
-
Pipeline: () => Pipeline
|
|
879
|
-
});
|
|
880
857
|
import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
|
|
881
858
|
function logPipelineConfig(generator, filters) {
|
|
882
859
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
@@ -936,6 +913,11 @@ var init_Pipeline = __esm({
|
|
|
936
913
|
this.filters = filters;
|
|
937
914
|
this.user = user;
|
|
938
915
|
this.course = course;
|
|
916
|
+
course.getCourseConfig().then((cfg) => {
|
|
917
|
+
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
918
|
+
}).catch((e) => {
|
|
919
|
+
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
920
|
+
});
|
|
939
921
|
logPipelineConfig(generator, filters);
|
|
940
922
|
}
|
|
941
923
|
/**
|
|
@@ -972,7 +954,13 @@ var init_Pipeline = __esm({
|
|
|
972
954
|
cards.sort((a, b) => b.score - a.score);
|
|
973
955
|
const result = cards.slice(0, limit);
|
|
974
956
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
975
|
-
logExecutionSummary(
|
|
957
|
+
logExecutionSummary(
|
|
958
|
+
this.generator.name,
|
|
959
|
+
generatedCount,
|
|
960
|
+
this.filters.length,
|
|
961
|
+
result.length,
|
|
962
|
+
topScores
|
|
963
|
+
);
|
|
976
964
|
logCardProvenance(result, 3);
|
|
977
965
|
return result;
|
|
978
966
|
}
|
|
@@ -1021,48 +1009,155 @@ var init_Pipeline = __esm({
|
|
|
1021
1009
|
userElo
|
|
1022
1010
|
};
|
|
1023
1011
|
}
|
|
1024
|
-
// ===========================================================================
|
|
1025
|
-
// Legacy StudyContentSource methods
|
|
1026
|
-
// ===========================================================================
|
|
1027
|
-
//
|
|
1028
|
-
// These delegate to the generator for backward compatibility.
|
|
1029
|
-
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
1030
|
-
//
|
|
1031
1012
|
/**
|
|
1032
|
-
* Get
|
|
1033
|
-
* Delegates to the generator if it supports the legacy interface.
|
|
1013
|
+
* Get the course ID for this pipeline.
|
|
1034
1014
|
*/
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1015
|
+
getCourseID() {
|
|
1016
|
+
return this.course.getCourseID();
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1023
|
+
var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
1024
|
+
var init_CompositeGenerator = __esm({
|
|
1025
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
1026
|
+
"use strict";
|
|
1027
|
+
init_navigators();
|
|
1028
|
+
init_logger();
|
|
1029
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
1030
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
1031
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
1032
|
+
/** Human-readable name for CardGenerator interface */
|
|
1033
|
+
name = "Composite Generator";
|
|
1034
|
+
generators;
|
|
1035
|
+
aggregationMode;
|
|
1036
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1037
|
+
super();
|
|
1038
|
+
this.generators = generators;
|
|
1039
|
+
this.aggregationMode = aggregationMode;
|
|
1040
|
+
if (generators.length === 0) {
|
|
1041
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
1038
1042
|
}
|
|
1039
|
-
|
|
1043
|
+
logger.debug(
|
|
1044
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
1045
|
+
);
|
|
1040
1046
|
}
|
|
1041
1047
|
/**
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1048
|
+
* Creates a CompositeGenerator from strategy data.
|
|
1049
|
+
*
|
|
1050
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
1044
1051
|
*/
|
|
1045
|
-
async
|
|
1046
|
-
|
|
1047
|
-
|
|
1052
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1053
|
+
const generators = await Promise.all(
|
|
1054
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
1055
|
+
);
|
|
1056
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
1060
|
+
*
|
|
1061
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
1062
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
1063
|
+
*
|
|
1064
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1065
|
+
* CardGenerator interface signature (limit, context).
|
|
1066
|
+
*
|
|
1067
|
+
* @param limit - Maximum number of cards to return
|
|
1068
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
1069
|
+
*/
|
|
1070
|
+
async getWeightedCards(limit, context) {
|
|
1071
|
+
if (!context) {
|
|
1072
|
+
throw new Error(
|
|
1073
|
+
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
const results = await Promise.all(
|
|
1077
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
1078
|
+
);
|
|
1079
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
1080
|
+
for (const cards of results) {
|
|
1081
|
+
for (const card of cards) {
|
|
1082
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
1083
|
+
existing.push(card);
|
|
1084
|
+
byCardId.set(card.cardId, existing);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const merged = [];
|
|
1088
|
+
for (const [, cards] of byCardId) {
|
|
1089
|
+
const aggregatedScore = this.aggregateScores(cards);
|
|
1090
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
1091
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1092
|
+
const initialScore = cards[0].score;
|
|
1093
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1094
|
+
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1095
|
+
merged.push({
|
|
1096
|
+
...cards[0],
|
|
1097
|
+
score: finalScore,
|
|
1098
|
+
provenance: [
|
|
1099
|
+
...mergedProvenance,
|
|
1100
|
+
{
|
|
1101
|
+
strategy: "composite",
|
|
1102
|
+
strategyName: "Composite Generator",
|
|
1103
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
1104
|
+
action,
|
|
1105
|
+
score: finalScore,
|
|
1106
|
+
reason
|
|
1107
|
+
}
|
|
1108
|
+
]
|
|
1109
|
+
});
|
|
1048
1110
|
}
|
|
1049
|
-
return
|
|
1111
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1050
1112
|
}
|
|
1051
1113
|
/**
|
|
1052
|
-
*
|
|
1114
|
+
* Build human-readable reason for score aggregation.
|
|
1053
1115
|
*/
|
|
1054
|
-
|
|
1055
|
-
|
|
1116
|
+
buildAggregationReason(cards, finalScore) {
|
|
1117
|
+
const count = cards.length;
|
|
1118
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1119
|
+
if (count === 1) {
|
|
1120
|
+
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1121
|
+
}
|
|
1122
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1123
|
+
switch (this.aggregationMode) {
|
|
1124
|
+
case "max" /* MAX */:
|
|
1125
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1126
|
+
case "average" /* AVERAGE */:
|
|
1127
|
+
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1128
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1129
|
+
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1130
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1131
|
+
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1132
|
+
}
|
|
1133
|
+
default:
|
|
1134
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Aggregate scores from multiple generators for the same card.
|
|
1139
|
+
*/
|
|
1140
|
+
aggregateScores(cards) {
|
|
1141
|
+
const scores = cards.map((c) => c.score);
|
|
1142
|
+
switch (this.aggregationMode) {
|
|
1143
|
+
case "max" /* MAX */:
|
|
1144
|
+
return Math.max(...scores);
|
|
1145
|
+
case "average" /* AVERAGE */:
|
|
1146
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1147
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1148
|
+
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1149
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1150
|
+
return avg * frequencyBoost;
|
|
1151
|
+
}
|
|
1152
|
+
default:
|
|
1153
|
+
return scores[0];
|
|
1154
|
+
}
|
|
1056
1155
|
}
|
|
1057
1156
|
};
|
|
1058
1157
|
}
|
|
1059
1158
|
});
|
|
1060
1159
|
|
|
1061
1160
|
// src/core/navigators/PipelineAssembler.ts
|
|
1062
|
-
var PipelineAssembler_exports = {};
|
|
1063
|
-
__export(PipelineAssembler_exports, {
|
|
1064
|
-
PipelineAssembler: () => PipelineAssembler
|
|
1065
|
-
});
|
|
1066
1161
|
var PipelineAssembler;
|
|
1067
1162
|
var init_PipelineAssembler = __esm({
|
|
1068
1163
|
"src/core/navigators/PipelineAssembler.ts"() {
|
|
@@ -1183,15 +1278,11 @@ var init_PipelineAssembler = __esm({
|
|
|
1183
1278
|
}
|
|
1184
1279
|
});
|
|
1185
1280
|
|
|
1186
|
-
// src/core/navigators/elo.ts
|
|
1187
|
-
var elo_exports = {};
|
|
1188
|
-
__export(elo_exports, {
|
|
1189
|
-
default: () => ELONavigator
|
|
1190
|
-
});
|
|
1281
|
+
// src/core/navigators/generators/elo.ts
|
|
1191
1282
|
import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
|
|
1192
1283
|
var ELONavigator;
|
|
1193
1284
|
var init_elo = __esm({
|
|
1194
|
-
"src/core/navigators/elo.ts"() {
|
|
1285
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
1195
1286
|
"use strict";
|
|
1196
1287
|
init_navigators();
|
|
1197
1288
|
ELONavigator = class extends ContentNavigator {
|
|
@@ -1201,50 +1292,6 @@ var init_elo = __esm({
|
|
|
1201
1292
|
super(user, course, strategyData);
|
|
1202
1293
|
this.name = strategyData?.name || "ELO";
|
|
1203
1294
|
}
|
|
1204
|
-
async getPendingReviews() {
|
|
1205
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1206
|
-
const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
|
|
1207
|
-
const ratedReviews = reviews.map((r, i) => {
|
|
1208
|
-
const ratedR = {
|
|
1209
|
-
...r,
|
|
1210
|
-
...elo[i]
|
|
1211
|
-
};
|
|
1212
|
-
return ratedR;
|
|
1213
|
-
});
|
|
1214
|
-
ratedReviews.sort((a, b) => {
|
|
1215
|
-
return a.global.score - b.global.score;
|
|
1216
|
-
});
|
|
1217
|
-
return ratedReviews.map((r) => {
|
|
1218
|
-
return {
|
|
1219
|
-
...r,
|
|
1220
|
-
contentSourceType: "course",
|
|
1221
|
-
contentSourceID: this.course.getCourseID(),
|
|
1222
|
-
cardID: r.cardId,
|
|
1223
|
-
courseID: r.courseId,
|
|
1224
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
1225
|
-
reviewID: r._id,
|
|
1226
|
-
status: "review"
|
|
1227
|
-
};
|
|
1228
|
-
});
|
|
1229
|
-
}
|
|
1230
|
-
async getNewCards(limit = 99) {
|
|
1231
|
-
const activeCards = await this.user.getActiveCards();
|
|
1232
|
-
return (await this.course.getCardsCenteredAtELO(
|
|
1233
|
-
{ limit, elo: "user" },
|
|
1234
|
-
(c) => {
|
|
1235
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
1236
|
-
return false;
|
|
1237
|
-
} else {
|
|
1238
|
-
return true;
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
)).map((c) => {
|
|
1242
|
-
return {
|
|
1243
|
-
...c,
|
|
1244
|
-
status: "new"
|
|
1245
|
-
};
|
|
1246
|
-
});
|
|
1247
|
-
}
|
|
1248
1295
|
/**
|
|
1249
1296
|
* Get new cards with suitability scores based on ELO distance.
|
|
1250
1297
|
*
|
|
@@ -1269,7 +1316,11 @@ var init_elo = __esm({
|
|
|
1269
1316
|
const userElo = toCourseElo3(courseReg.elo);
|
|
1270
1317
|
userGlobalElo = userElo.global.score;
|
|
1271
1318
|
}
|
|
1272
|
-
const
|
|
1319
|
+
const activeCards = await this.user.getActiveCards();
|
|
1320
|
+
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
1321
|
+
{ limit, elo: "user" },
|
|
1322
|
+
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
1323
|
+
)).map((c) => ({ ...c, status: "new" }));
|
|
1273
1324
|
const cardIds = newCards.map((c) => c.cardID);
|
|
1274
1325
|
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1275
1326
|
const scored = newCards.map((c, i) => {
|
|
@@ -1299,950 +1350,39 @@ var init_elo = __esm({
|
|
|
1299
1350
|
}
|
|
1300
1351
|
});
|
|
1301
1352
|
|
|
1302
|
-
// src/core/navigators/
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1308
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1309
|
-
});
|
|
1310
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1311
|
-
const normalizedDistance = distance / halfLife;
|
|
1312
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1313
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1314
|
-
}
|
|
1315
|
-
function createEloDistanceFilter(config) {
|
|
1316
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1317
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1318
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1319
|
-
return {
|
|
1320
|
-
name: "ELO Distance Filter",
|
|
1321
|
-
async transform(cards, context) {
|
|
1322
|
-
const { course, userElo } = context;
|
|
1323
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1324
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1325
|
-
return cards.map((card, i) => {
|
|
1326
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1327
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1328
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1329
|
-
const newScore = card.score * multiplier;
|
|
1330
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1331
|
-
return {
|
|
1332
|
-
...card,
|
|
1333
|
-
score: newScore,
|
|
1334
|
-
provenance: [
|
|
1335
|
-
...card.provenance,
|
|
1336
|
-
{
|
|
1337
|
-
strategy: "eloDistance",
|
|
1338
|
-
strategyName: "ELO Distance Filter",
|
|
1339
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1340
|
-
action,
|
|
1341
|
-
score: newScore,
|
|
1342
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1343
|
-
}
|
|
1344
|
-
]
|
|
1345
|
-
};
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
|
-
};
|
|
1349
|
-
}
|
|
1350
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1351
|
-
var init_eloDistance = __esm({
|
|
1352
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1353
|
-
"use strict";
|
|
1354
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1355
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1356
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1357
|
-
}
|
|
1358
|
-
});
|
|
1359
|
-
|
|
1360
|
-
// src/core/navigators/filters/userTagPreference.ts
|
|
1361
|
-
var userTagPreference_exports = {};
|
|
1362
|
-
__export(userTagPreference_exports, {
|
|
1363
|
-
default: () => UserTagPreferenceFilter
|
|
1364
|
-
});
|
|
1365
|
-
var UserTagPreferenceFilter;
|
|
1366
|
-
var init_userTagPreference = __esm({
|
|
1367
|
-
"src/core/navigators/filters/userTagPreference.ts"() {
|
|
1353
|
+
// src/core/navigators/generators/srs.ts
|
|
1354
|
+
import moment3 from "moment";
|
|
1355
|
+
var SRSNavigator;
|
|
1356
|
+
var init_srs = __esm({
|
|
1357
|
+
"src/core/navigators/generators/srs.ts"() {
|
|
1368
1358
|
"use strict";
|
|
1369
1359
|
init_navigators();
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
/** Human-readable name for
|
|
1360
|
+
init_logger();
|
|
1361
|
+
SRSNavigator = class extends ContentNavigator {
|
|
1362
|
+
/** Human-readable name for CardGenerator interface */
|
|
1373
1363
|
name;
|
|
1374
1364
|
constructor(user, course, strategyData) {
|
|
1375
1365
|
super(user, course, strategyData);
|
|
1376
|
-
this.
|
|
1377
|
-
this.name = strategyData.name || "User Tag Preferences";
|
|
1378
|
-
}
|
|
1379
|
-
/**
|
|
1380
|
-
* Compute multiplier for a card based on its tags and user preferences.
|
|
1381
|
-
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
1382
|
-
*/
|
|
1383
|
-
computeMultiplier(cardTags, boostMap) {
|
|
1384
|
-
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
|
|
1385
|
-
if (multipliers.length === 0) {
|
|
1386
|
-
return 1;
|
|
1387
|
-
}
|
|
1388
|
-
return Math.max(...multipliers);
|
|
1389
|
-
}
|
|
1390
|
-
/**
|
|
1391
|
-
* Build human-readable reason for the filter's decision.
|
|
1392
|
-
*/
|
|
1393
|
-
buildReason(cardTags, boostMap, multiplier) {
|
|
1394
|
-
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
1395
|
-
if (multiplier === 0) {
|
|
1396
|
-
return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
|
|
1397
|
-
}
|
|
1398
|
-
if (multiplier < 1) {
|
|
1399
|
-
return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1400
|
-
}
|
|
1401
|
-
if (multiplier > 1) {
|
|
1402
|
-
return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1403
|
-
}
|
|
1404
|
-
return "No matching user preferences";
|
|
1366
|
+
this.name = strategyData?.name || "SRS";
|
|
1405
1367
|
}
|
|
1406
1368
|
/**
|
|
1407
|
-
*
|
|
1369
|
+
* Get review cards scored by urgency.
|
|
1370
|
+
*
|
|
1371
|
+
* Score formula combines:
|
|
1372
|
+
* - Relative overdueness: hoursOverdue / intervalHours
|
|
1373
|
+
* - Interval recency: exponential decay favoring shorter intervals
|
|
1408
1374
|
*
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
1416
|
-
* - Append provenance with clear reason
|
|
1375
|
+
* Cards not yet due are excluded (not scored as 0).
|
|
1376
|
+
*
|
|
1377
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1378
|
+
* CardGenerator interface signature (limit, context).
|
|
1379
|
+
*
|
|
1380
|
+
* @param limit - Maximum number of cards to return
|
|
1381
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1417
1382
|
*/
|
|
1418
|
-
async
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
return cards.map((card) => ({
|
|
1422
|
-
...card,
|
|
1423
|
-
provenance: [
|
|
1424
|
-
...card.provenance,
|
|
1425
|
-
{
|
|
1426
|
-
strategy: "userTagPreference",
|
|
1427
|
-
strategyName: this.strategyName || this.name,
|
|
1428
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1429
|
-
action: "passed",
|
|
1430
|
-
score: card.score,
|
|
1431
|
-
reason: "No user tag preferences configured"
|
|
1432
|
-
}
|
|
1433
|
-
]
|
|
1434
|
-
}));
|
|
1435
|
-
}
|
|
1436
|
-
const adjusted = await Promise.all(
|
|
1437
|
-
cards.map(async (card) => {
|
|
1438
|
-
const cardTags = card.tags ?? [];
|
|
1439
|
-
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
1440
|
-
const finalScore = Math.min(1, card.score * multiplier);
|
|
1441
|
-
let action;
|
|
1442
|
-
if (multiplier === 0 || multiplier < 1) {
|
|
1443
|
-
action = "penalized";
|
|
1444
|
-
} else if (multiplier > 1) {
|
|
1445
|
-
action = "boosted";
|
|
1446
|
-
} else {
|
|
1447
|
-
action = "passed";
|
|
1448
|
-
}
|
|
1449
|
-
return {
|
|
1450
|
-
...card,
|
|
1451
|
-
score: finalScore,
|
|
1452
|
-
provenance: [
|
|
1453
|
-
...card.provenance,
|
|
1454
|
-
{
|
|
1455
|
-
strategy: "userTagPreference",
|
|
1456
|
-
strategyName: this.strategyName || this.name,
|
|
1457
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1458
|
-
action,
|
|
1459
|
-
score: finalScore,
|
|
1460
|
-
reason: this.buildReason(cardTags, prefs.boost, multiplier)
|
|
1461
|
-
}
|
|
1462
|
-
]
|
|
1463
|
-
};
|
|
1464
|
-
})
|
|
1465
|
-
);
|
|
1466
|
-
return adjusted;
|
|
1467
|
-
}
|
|
1468
|
-
/**
|
|
1469
|
-
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
1470
|
-
*/
|
|
1471
|
-
async getWeightedCards(_limit) {
|
|
1472
|
-
throw new Error(
|
|
1473
|
-
"UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1474
|
-
);
|
|
1475
|
-
}
|
|
1476
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1477
|
-
async getNewCards(_n) {
|
|
1478
|
-
return [];
|
|
1479
|
-
}
|
|
1480
|
-
async getPendingReviews() {
|
|
1481
|
-
return [];
|
|
1482
|
-
}
|
|
1483
|
-
};
|
|
1484
|
-
}
|
|
1485
|
-
});
|
|
1486
|
-
|
|
1487
|
-
// src/core/navigators/filters/index.ts
|
|
1488
|
-
var filters_exports = {};
|
|
1489
|
-
__export(filters_exports, {
|
|
1490
|
-
UserTagPreferenceFilter: () => UserTagPreferenceFilter,
|
|
1491
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1492
|
-
});
|
|
1493
|
-
var init_filters = __esm({
|
|
1494
|
-
"src/core/navigators/filters/index.ts"() {
|
|
1495
|
-
"use strict";
|
|
1496
|
-
init_eloDistance();
|
|
1497
|
-
init_userTagPreference();
|
|
1498
|
-
}
|
|
1499
|
-
});
|
|
1500
|
-
|
|
1501
|
-
// src/core/navigators/filters/types.ts
|
|
1502
|
-
var types_exports = {};
|
|
1503
|
-
var init_types = __esm({
|
|
1504
|
-
"src/core/navigators/filters/types.ts"() {
|
|
1505
|
-
"use strict";
|
|
1506
|
-
}
|
|
1507
|
-
});
|
|
1508
|
-
|
|
1509
|
-
// src/core/navigators/generators/index.ts
|
|
1510
|
-
var generators_exports = {};
|
|
1511
|
-
var init_generators = __esm({
|
|
1512
|
-
"src/core/navigators/generators/index.ts"() {
|
|
1513
|
-
"use strict";
|
|
1514
|
-
}
|
|
1515
|
-
});
|
|
1516
|
-
|
|
1517
|
-
// src/core/navigators/generators/types.ts
|
|
1518
|
-
var types_exports2 = {};
|
|
1519
|
-
var init_types2 = __esm({
|
|
1520
|
-
"src/core/navigators/generators/types.ts"() {
|
|
1521
|
-
"use strict";
|
|
1522
|
-
}
|
|
1523
|
-
});
|
|
1524
|
-
|
|
1525
|
-
// src/core/navigators/hardcodedOrder.ts
|
|
1526
|
-
var hardcodedOrder_exports = {};
|
|
1527
|
-
__export(hardcodedOrder_exports, {
|
|
1528
|
-
default: () => HardcodedOrderNavigator
|
|
1529
|
-
});
|
|
1530
|
-
var HardcodedOrderNavigator;
|
|
1531
|
-
var init_hardcodedOrder = __esm({
|
|
1532
|
-
"src/core/navigators/hardcodedOrder.ts"() {
|
|
1533
|
-
"use strict";
|
|
1534
|
-
init_navigators();
|
|
1535
|
-
init_logger();
|
|
1536
|
-
HardcodedOrderNavigator = class extends ContentNavigator {
|
|
1537
|
-
/** Human-readable name for CardGenerator interface */
|
|
1538
|
-
name;
|
|
1539
|
-
orderedCardIds = [];
|
|
1540
|
-
constructor(user, course, strategyData) {
|
|
1541
|
-
super(user, course, strategyData);
|
|
1542
|
-
this.name = strategyData.name || "Hardcoded Order";
|
|
1543
|
-
if (strategyData.serializedData) {
|
|
1544
|
-
try {
|
|
1545
|
-
this.orderedCardIds = JSON.parse(strategyData.serializedData);
|
|
1546
|
-
} catch (e) {
|
|
1547
|
-
logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
async getPendingReviews() {
|
|
1552
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1553
|
-
return reviews.map((r) => {
|
|
1554
|
-
return {
|
|
1555
|
-
...r,
|
|
1556
|
-
contentSourceType: "course",
|
|
1557
|
-
contentSourceID: this.course.getCourseID(),
|
|
1558
|
-
cardID: r.cardId,
|
|
1559
|
-
courseID: r.courseId,
|
|
1560
|
-
reviewID: r._id,
|
|
1561
|
-
status: "review"
|
|
1562
|
-
};
|
|
1563
|
-
});
|
|
1564
|
-
}
|
|
1565
|
-
async getNewCards(limit = 99) {
|
|
1566
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1567
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1568
|
-
const cardsToReturn = newCardIds.slice(0, limit);
|
|
1569
|
-
return cardsToReturn.map((cardId) => {
|
|
1570
|
-
return {
|
|
1571
|
-
cardID: cardId,
|
|
1572
|
-
courseID: this.course.getCourseID(),
|
|
1573
|
-
contentSourceType: "course",
|
|
1574
|
-
contentSourceID: this.course.getCourseID(),
|
|
1575
|
-
status: "new"
|
|
1576
|
-
};
|
|
1577
|
-
});
|
|
1578
|
-
}
|
|
1579
|
-
/**
|
|
1580
|
-
* Get cards in hardcoded order with scores based on position.
|
|
1581
|
-
*
|
|
1582
|
-
* Earlier cards in the sequence get higher scores.
|
|
1583
|
-
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
1584
|
-
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
1585
|
-
*
|
|
1586
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1587
|
-
* CardGenerator interface signature (limit, context).
|
|
1588
|
-
*
|
|
1589
|
-
* @param limit - Maximum number of cards to return
|
|
1590
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1591
|
-
*/
|
|
1592
|
-
async getWeightedCards(limit, _context) {
|
|
1593
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1594
|
-
const reviews = await this.getPendingReviews();
|
|
1595
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1596
|
-
const totalCards = newCardIds.length;
|
|
1597
|
-
const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
1598
|
-
const position = index + 1;
|
|
1599
|
-
const score = Math.max(0.5, 1 - index / totalCards * 0.5);
|
|
1600
|
-
return {
|
|
1601
|
-
cardId,
|
|
1602
|
-
courseId: this.course.getCourseID(),
|
|
1603
|
-
score,
|
|
1604
|
-
provenance: [
|
|
1605
|
-
{
|
|
1606
|
-
strategy: "hardcodedOrder",
|
|
1607
|
-
strategyName: this.strategyName || this.name,
|
|
1608
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1609
|
-
action: "generated",
|
|
1610
|
-
score,
|
|
1611
|
-
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
|
|
1612
|
-
}
|
|
1613
|
-
]
|
|
1614
|
-
};
|
|
1615
|
-
});
|
|
1616
|
-
const scoredReviews = reviews.map((r) => ({
|
|
1617
|
-
cardId: r.cardID,
|
|
1618
|
-
courseId: r.courseID,
|
|
1619
|
-
score: 1,
|
|
1620
|
-
provenance: [
|
|
1621
|
-
{
|
|
1622
|
-
strategy: "hardcodedOrder",
|
|
1623
|
-
strategyName: this.strategyName || this.name,
|
|
1624
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1625
|
-
action: "generated",
|
|
1626
|
-
score: 1,
|
|
1627
|
-
reason: "Scheduled review, highest priority"
|
|
1628
|
-
}
|
|
1629
|
-
]
|
|
1630
|
-
}));
|
|
1631
|
-
const all = [...scoredReviews, ...scoredNew];
|
|
1632
|
-
all.sort((a, b) => b.score - a.score);
|
|
1633
|
-
return all.slice(0, limit);
|
|
1634
|
-
}
|
|
1635
|
-
};
|
|
1636
|
-
}
|
|
1637
|
-
});
|
|
1638
|
-
|
|
1639
|
-
// src/core/navigators/hierarchyDefinition.ts
|
|
1640
|
-
var hierarchyDefinition_exports = {};
|
|
1641
|
-
__export(hierarchyDefinition_exports, {
|
|
1642
|
-
default: () => HierarchyDefinitionNavigator
|
|
1643
|
-
});
|
|
1644
|
-
import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
|
|
1645
|
-
var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1646
|
-
var init_hierarchyDefinition = __esm({
|
|
1647
|
-
"src/core/navigators/hierarchyDefinition.ts"() {
|
|
1648
|
-
"use strict";
|
|
1649
|
-
init_navigators();
|
|
1650
|
-
DEFAULT_MIN_COUNT = 3;
|
|
1651
|
-
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1652
|
-
config;
|
|
1653
|
-
_strategyData;
|
|
1654
|
-
/** Human-readable name for CardFilter interface */
|
|
1655
|
-
name;
|
|
1656
|
-
constructor(user, course, _strategyData) {
|
|
1657
|
-
super(user, course, _strategyData);
|
|
1658
|
-
this._strategyData = _strategyData;
|
|
1659
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1660
|
-
this.name = _strategyData.name || "Hierarchy Definition";
|
|
1661
|
-
}
|
|
1662
|
-
parseConfig(serializedData) {
|
|
1663
|
-
try {
|
|
1664
|
-
const parsed = JSON.parse(serializedData);
|
|
1665
|
-
return {
|
|
1666
|
-
prerequisites: parsed.prerequisites || {}
|
|
1667
|
-
};
|
|
1668
|
-
} catch {
|
|
1669
|
-
return {
|
|
1670
|
-
prerequisites: {}
|
|
1671
|
-
};
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
/**
|
|
1675
|
-
* Check if a specific prerequisite is satisfied
|
|
1676
|
-
*/
|
|
1677
|
-
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1678
|
-
if (!userTagElo) return false;
|
|
1679
|
-
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1680
|
-
if (userTagElo.count < minCount) return false;
|
|
1681
|
-
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1682
|
-
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1683
|
-
} else {
|
|
1684
|
-
return userTagElo.score >= userGlobalElo;
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
/**
|
|
1688
|
-
* Get the set of tags the user has mastered.
|
|
1689
|
-
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1690
|
-
*/
|
|
1691
|
-
async getMasteredTags(context) {
|
|
1692
|
-
const mastered = /* @__PURE__ */ new Set();
|
|
1693
|
-
try {
|
|
1694
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1695
|
-
const userElo = toCourseElo4(courseReg.elo);
|
|
1696
|
-
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1697
|
-
for (const prereq of prereqs) {
|
|
1698
|
-
const tagElo = userElo.tags[prereq.tag];
|
|
1699
|
-
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1700
|
-
mastered.add(prereq.tag);
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
} catch {
|
|
1705
|
-
}
|
|
1706
|
-
return mastered;
|
|
1707
|
-
}
|
|
1708
|
-
/**
|
|
1709
|
-
* Get the set of tags that are unlocked (prerequisites met)
|
|
1710
|
-
*/
|
|
1711
|
-
getUnlockedTags(masteredTags) {
|
|
1712
|
-
const unlocked = /* @__PURE__ */ new Set();
|
|
1713
|
-
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1714
|
-
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1715
|
-
if (allPrereqsMet) {
|
|
1716
|
-
unlocked.add(tagId);
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
return unlocked;
|
|
1720
|
-
}
|
|
1721
|
-
/**
|
|
1722
|
-
* Check if a tag has prerequisites defined in config
|
|
1723
|
-
*/
|
|
1724
|
-
hasPrerequisites(tagId) {
|
|
1725
|
-
return tagId in this.config.prerequisites;
|
|
1726
|
-
}
|
|
1727
|
-
/**
|
|
1728
|
-
* Check if a card is unlocked and generate reason.
|
|
1729
|
-
*/
|
|
1730
|
-
async checkCardUnlock(card, course, unlockedTags, masteredTags) {
|
|
1731
|
-
try {
|
|
1732
|
-
const cardTags = card.tags ?? [];
|
|
1733
|
-
const lockedTags = cardTags.filter(
|
|
1734
|
-
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1735
|
-
);
|
|
1736
|
-
if (lockedTags.length === 0) {
|
|
1737
|
-
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1738
|
-
return {
|
|
1739
|
-
isUnlocked: true,
|
|
1740
|
-
reason: `Prerequisites met, tags: ${tagList}`
|
|
1741
|
-
};
|
|
1742
|
-
}
|
|
1743
|
-
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1744
|
-
const prereqs = this.config.prerequisites[tag] || [];
|
|
1745
|
-
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1746
|
-
});
|
|
1747
|
-
return {
|
|
1748
|
-
isUnlocked: false,
|
|
1749
|
-
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1750
|
-
};
|
|
1751
|
-
} catch {
|
|
1752
|
-
return {
|
|
1753
|
-
isUnlocked: true,
|
|
1754
|
-
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1755
|
-
};
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
/**
|
|
1759
|
-
* CardFilter.transform implementation.
|
|
1760
|
-
*
|
|
1761
|
-
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1762
|
-
*/
|
|
1763
|
-
async transform(cards, context) {
|
|
1764
|
-
const masteredTags = await this.getMasteredTags(context);
|
|
1765
|
-
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1766
|
-
const gated = [];
|
|
1767
|
-
for (const card of cards) {
|
|
1768
|
-
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1769
|
-
card,
|
|
1770
|
-
context.course,
|
|
1771
|
-
unlockedTags,
|
|
1772
|
-
masteredTags
|
|
1773
|
-
);
|
|
1774
|
-
const finalScore = isUnlocked ? card.score : 0;
|
|
1775
|
-
const action = isUnlocked ? "passed" : "penalized";
|
|
1776
|
-
gated.push({
|
|
1777
|
-
...card,
|
|
1778
|
-
score: finalScore,
|
|
1779
|
-
provenance: [
|
|
1780
|
-
...card.provenance,
|
|
1781
|
-
{
|
|
1782
|
-
strategy: "hierarchyDefinition",
|
|
1783
|
-
strategyName: this.strategyName || this.name,
|
|
1784
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1785
|
-
action,
|
|
1786
|
-
score: finalScore,
|
|
1787
|
-
reason
|
|
1788
|
-
}
|
|
1789
|
-
]
|
|
1790
|
-
});
|
|
1791
|
-
}
|
|
1792
|
-
return gated;
|
|
1793
|
-
}
|
|
1794
|
-
/**
|
|
1795
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1796
|
-
*
|
|
1797
|
-
* Use transform() via Pipeline instead.
|
|
1798
|
-
*/
|
|
1799
|
-
async getWeightedCards(_limit) {
|
|
1800
|
-
throw new Error(
|
|
1801
|
-
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1802
|
-
);
|
|
1803
|
-
}
|
|
1804
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1805
|
-
async getNewCards(_n) {
|
|
1806
|
-
return [];
|
|
1807
|
-
}
|
|
1808
|
-
async getPendingReviews() {
|
|
1809
|
-
return [];
|
|
1810
|
-
}
|
|
1811
|
-
};
|
|
1812
|
-
}
|
|
1813
|
-
});
|
|
1814
|
-
|
|
1815
|
-
// src/core/navigators/inferredPreference.ts
|
|
1816
|
-
var inferredPreference_exports = {};
|
|
1817
|
-
__export(inferredPreference_exports, {
|
|
1818
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
|
|
1819
|
-
});
|
|
1820
|
-
var INFERRED_PREFERENCE_NAVIGATOR_STUB;
|
|
1821
|
-
var init_inferredPreference = __esm({
|
|
1822
|
-
"src/core/navigators/inferredPreference.ts"() {
|
|
1823
|
-
"use strict";
|
|
1824
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
|
|
1825
|
-
}
|
|
1826
|
-
});
|
|
1827
|
-
|
|
1828
|
-
// src/core/navigators/interferenceMitigator.ts
|
|
1829
|
-
var interferenceMitigator_exports = {};
|
|
1830
|
-
__export(interferenceMitigator_exports, {
|
|
1831
|
-
default: () => InterferenceMitigatorNavigator
|
|
1832
|
-
});
|
|
1833
|
-
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
1834
|
-
var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
1835
|
-
var init_interferenceMitigator = __esm({
|
|
1836
|
-
"src/core/navigators/interferenceMitigator.ts"() {
|
|
1837
|
-
"use strict";
|
|
1838
|
-
init_navigators();
|
|
1839
|
-
DEFAULT_MIN_COUNT2 = 10;
|
|
1840
|
-
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
1841
|
-
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
1842
|
-
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
1843
|
-
config;
|
|
1844
|
-
_strategyData;
|
|
1845
|
-
/** Human-readable name for CardFilter interface */
|
|
1846
|
-
name;
|
|
1847
|
-
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
1848
|
-
interferenceMap;
|
|
1849
|
-
constructor(user, course, _strategyData) {
|
|
1850
|
-
super(user, course, _strategyData);
|
|
1851
|
-
this._strategyData = _strategyData;
|
|
1852
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1853
|
-
this.interferenceMap = this.buildInterferenceMap();
|
|
1854
|
-
this.name = _strategyData.name || "Interference Mitigator";
|
|
1855
|
-
}
|
|
1856
|
-
parseConfig(serializedData) {
|
|
1857
|
-
try {
|
|
1858
|
-
const parsed = JSON.parse(serializedData);
|
|
1859
|
-
let sets = parsed.interferenceSets || [];
|
|
1860
|
-
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
1861
|
-
sets = sets.map((tags) => ({ tags }));
|
|
1862
|
-
}
|
|
1863
|
-
return {
|
|
1864
|
-
interferenceSets: sets,
|
|
1865
|
-
maturityThreshold: {
|
|
1866
|
-
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
1867
|
-
minElo: parsed.maturityThreshold?.minElo,
|
|
1868
|
-
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
1869
|
-
},
|
|
1870
|
-
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
1871
|
-
};
|
|
1872
|
-
} catch {
|
|
1873
|
-
return {
|
|
1874
|
-
interferenceSets: [],
|
|
1875
|
-
maturityThreshold: {
|
|
1876
|
-
minCount: DEFAULT_MIN_COUNT2,
|
|
1877
|
-
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
1878
|
-
},
|
|
1879
|
-
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
1880
|
-
};
|
|
1881
|
-
}
|
|
1882
|
-
}
|
|
1883
|
-
/**
|
|
1884
|
-
* Build a map from each tag to its interference partners with decay coefficients.
|
|
1885
|
-
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
1886
|
-
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
1887
|
-
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
1888
|
-
* - etc.
|
|
1889
|
-
*/
|
|
1890
|
-
buildInterferenceMap() {
|
|
1891
|
-
const map = /* @__PURE__ */ new Map();
|
|
1892
|
-
for (const group of this.config.interferenceSets) {
|
|
1893
|
-
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
1894
|
-
for (const tag of group.tags) {
|
|
1895
|
-
if (!map.has(tag)) {
|
|
1896
|
-
map.set(tag, []);
|
|
1897
|
-
}
|
|
1898
|
-
const partners = map.get(tag);
|
|
1899
|
-
for (const other of group.tags) {
|
|
1900
|
-
if (other !== tag) {
|
|
1901
|
-
const existing = partners.find((p) => p.partner === other);
|
|
1902
|
-
if (existing) {
|
|
1903
|
-
existing.decay = Math.max(existing.decay, decay);
|
|
1904
|
-
} else {
|
|
1905
|
-
partners.push({ partner: other, decay });
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
return map;
|
|
1912
|
-
}
|
|
1913
|
-
/**
|
|
1914
|
-
* Get the set of tags that are currently immature for this user.
|
|
1915
|
-
* A tag is immature if the user has interacted with it but hasn't
|
|
1916
|
-
* reached the maturity threshold.
|
|
1917
|
-
*/
|
|
1918
|
-
async getImmatureTags(context) {
|
|
1919
|
-
const immature = /* @__PURE__ */ new Set();
|
|
1920
|
-
try {
|
|
1921
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1922
|
-
const userElo = toCourseElo5(courseReg.elo);
|
|
1923
|
-
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1924
|
-
const minElo = this.config.maturityThreshold?.minElo;
|
|
1925
|
-
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
1926
|
-
const minCountForElapsed = minElapsedDays * 2;
|
|
1927
|
-
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
1928
|
-
if (tagElo.count === 0) continue;
|
|
1929
|
-
const belowCount = tagElo.count < minCount;
|
|
1930
|
-
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
1931
|
-
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
1932
|
-
if (belowCount || belowElo || belowElapsed) {
|
|
1933
|
-
immature.add(tagId);
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
} catch {
|
|
1937
|
-
}
|
|
1938
|
-
return immature;
|
|
1939
|
-
}
|
|
1940
|
-
/**
|
|
1941
|
-
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
1942
|
-
* These are the tags we want to avoid introducing.
|
|
1943
|
-
*/
|
|
1944
|
-
getTagsToAvoid(immatureTags) {
|
|
1945
|
-
const avoid = /* @__PURE__ */ new Map();
|
|
1946
|
-
for (const immatureTag of immatureTags) {
|
|
1947
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
1948
|
-
if (partners) {
|
|
1949
|
-
for (const { partner, decay } of partners) {
|
|
1950
|
-
if (!immatureTags.has(partner)) {
|
|
1951
|
-
const existing = avoid.get(partner) ?? 0;
|
|
1952
|
-
avoid.set(partner, Math.max(existing, decay));
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
return avoid;
|
|
1958
|
-
}
|
|
1959
|
-
/**
|
|
1960
|
-
* Compute interference score reduction for a card.
|
|
1961
|
-
* Returns: { multiplier, interfering tags, reason }
|
|
1962
|
-
*/
|
|
1963
|
-
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
1964
|
-
if (tagsToAvoid.size === 0) {
|
|
1965
|
-
return {
|
|
1966
|
-
multiplier: 1,
|
|
1967
|
-
interferingTags: [],
|
|
1968
|
-
reason: "No interference detected"
|
|
1969
|
-
};
|
|
1970
|
-
}
|
|
1971
|
-
let multiplier = 1;
|
|
1972
|
-
const interferingTags = [];
|
|
1973
|
-
for (const tag of cardTags) {
|
|
1974
|
-
const decay = tagsToAvoid.get(tag);
|
|
1975
|
-
if (decay !== void 0) {
|
|
1976
|
-
interferingTags.push(tag);
|
|
1977
|
-
multiplier *= 1 - decay;
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
if (interferingTags.length === 0) {
|
|
1981
|
-
return {
|
|
1982
|
-
multiplier: 1,
|
|
1983
|
-
interferingTags: [],
|
|
1984
|
-
reason: "No interference detected"
|
|
1985
|
-
};
|
|
1986
|
-
}
|
|
1987
|
-
const causingTags = /* @__PURE__ */ new Set();
|
|
1988
|
-
for (const tag of interferingTags) {
|
|
1989
|
-
for (const immatureTag of immatureTags) {
|
|
1990
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
1991
|
-
if (partners?.some((p) => p.partner === tag)) {
|
|
1992
|
-
causingTags.add(immatureTag);
|
|
1993
|
-
}
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
1997
|
-
return { multiplier, interferingTags, reason };
|
|
1998
|
-
}
|
|
1999
|
-
/**
|
|
2000
|
-
* CardFilter.transform implementation.
|
|
2001
|
-
*
|
|
2002
|
-
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
2003
|
-
* immature learnings get reduced scores.
|
|
2004
|
-
*/
|
|
2005
|
-
async transform(cards, context) {
|
|
2006
|
-
const immatureTags = await this.getImmatureTags(context);
|
|
2007
|
-
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
2008
|
-
const adjusted = [];
|
|
2009
|
-
for (const card of cards) {
|
|
2010
|
-
const cardTags = card.tags ?? [];
|
|
2011
|
-
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
2012
|
-
cardTags,
|
|
2013
|
-
tagsToAvoid,
|
|
2014
|
-
immatureTags
|
|
2015
|
-
);
|
|
2016
|
-
const finalScore = card.score * multiplier;
|
|
2017
|
-
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
2018
|
-
adjusted.push({
|
|
2019
|
-
...card,
|
|
2020
|
-
score: finalScore,
|
|
2021
|
-
provenance: [
|
|
2022
|
-
...card.provenance,
|
|
2023
|
-
{
|
|
2024
|
-
strategy: "interferenceMitigator",
|
|
2025
|
-
strategyName: this.strategyName || this.name,
|
|
2026
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
2027
|
-
action,
|
|
2028
|
-
score: finalScore,
|
|
2029
|
-
reason
|
|
2030
|
-
}
|
|
2031
|
-
]
|
|
2032
|
-
});
|
|
2033
|
-
}
|
|
2034
|
-
return adjusted;
|
|
2035
|
-
}
|
|
2036
|
-
/**
|
|
2037
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2038
|
-
*
|
|
2039
|
-
* Use transform() via Pipeline instead.
|
|
2040
|
-
*/
|
|
2041
|
-
async getWeightedCards(_limit) {
|
|
2042
|
-
throw new Error(
|
|
2043
|
-
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2044
|
-
);
|
|
2045
|
-
}
|
|
2046
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2047
|
-
async getNewCards(_n) {
|
|
2048
|
-
return [];
|
|
2049
|
-
}
|
|
2050
|
-
async getPendingReviews() {
|
|
2051
|
-
return [];
|
|
2052
|
-
}
|
|
2053
|
-
};
|
|
2054
|
-
}
|
|
2055
|
-
});
|
|
2056
|
-
|
|
2057
|
-
// src/core/navigators/relativePriority.ts
|
|
2058
|
-
var relativePriority_exports = {};
|
|
2059
|
-
__export(relativePriority_exports, {
|
|
2060
|
-
default: () => RelativePriorityNavigator
|
|
2061
|
-
});
|
|
2062
|
-
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
2063
|
-
var init_relativePriority = __esm({
|
|
2064
|
-
"src/core/navigators/relativePriority.ts"() {
|
|
2065
|
-
"use strict";
|
|
2066
|
-
init_navigators();
|
|
2067
|
-
DEFAULT_PRIORITY = 0.5;
|
|
2068
|
-
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
2069
|
-
DEFAULT_COMBINE_MODE = "max";
|
|
2070
|
-
RelativePriorityNavigator = class extends ContentNavigator {
|
|
2071
|
-
config;
|
|
2072
|
-
_strategyData;
|
|
2073
|
-
/** Human-readable name for CardFilter interface */
|
|
2074
|
-
name;
|
|
2075
|
-
constructor(user, course, _strategyData) {
|
|
2076
|
-
super(user, course, _strategyData);
|
|
2077
|
-
this._strategyData = _strategyData;
|
|
2078
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2079
|
-
this.name = _strategyData.name || "Relative Priority";
|
|
2080
|
-
}
|
|
2081
|
-
parseConfig(serializedData) {
|
|
2082
|
-
try {
|
|
2083
|
-
const parsed = JSON.parse(serializedData);
|
|
2084
|
-
return {
|
|
2085
|
-
tagPriorities: parsed.tagPriorities || {},
|
|
2086
|
-
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
2087
|
-
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2088
|
-
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2089
|
-
};
|
|
2090
|
-
} catch {
|
|
2091
|
-
return {
|
|
2092
|
-
tagPriorities: {},
|
|
2093
|
-
defaultPriority: DEFAULT_PRIORITY,
|
|
2094
|
-
combineMode: DEFAULT_COMBINE_MODE,
|
|
2095
|
-
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2096
|
-
};
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
/**
|
|
2100
|
-
* Look up the priority for a tag.
|
|
2101
|
-
*/
|
|
2102
|
-
getTagPriority(tagId) {
|
|
2103
|
-
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2104
|
-
}
|
|
2105
|
-
/**
|
|
2106
|
-
* Compute combined priority for a card based on its tags.
|
|
2107
|
-
*/
|
|
2108
|
-
computeCardPriority(cardTags) {
|
|
2109
|
-
if (cardTags.length === 0) {
|
|
2110
|
-
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2111
|
-
}
|
|
2112
|
-
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2113
|
-
switch (this.config.combineMode) {
|
|
2114
|
-
case "max":
|
|
2115
|
-
return Math.max(...priorities);
|
|
2116
|
-
case "min":
|
|
2117
|
-
return Math.min(...priorities);
|
|
2118
|
-
case "average":
|
|
2119
|
-
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2120
|
-
default:
|
|
2121
|
-
return Math.max(...priorities);
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
/**
|
|
2125
|
-
* Compute boost factor based on priority.
|
|
2126
|
-
*
|
|
2127
|
-
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2128
|
-
*
|
|
2129
|
-
* This creates a multiplier centered around 1.0:
|
|
2130
|
-
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2131
|
-
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2132
|
-
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2133
|
-
*/
|
|
2134
|
-
computeBoostFactor(priority) {
|
|
2135
|
-
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2136
|
-
return 1 + (priority - 0.5) * influence;
|
|
2137
|
-
}
|
|
2138
|
-
/**
|
|
2139
|
-
* Build human-readable reason for priority adjustment.
|
|
2140
|
-
*/
|
|
2141
|
-
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2142
|
-
if (cardTags.length === 0) {
|
|
2143
|
-
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2144
|
-
}
|
|
2145
|
-
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2146
|
-
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2147
|
-
if (boostFactor === 1) {
|
|
2148
|
-
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2149
|
-
} else if (boostFactor > 1) {
|
|
2150
|
-
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2151
|
-
} else {
|
|
2152
|
-
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2155
|
-
/**
|
|
2156
|
-
* CardFilter.transform implementation.
|
|
2157
|
-
*
|
|
2158
|
-
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2159
|
-
* cards with low-priority tags get reduced scores.
|
|
2160
|
-
*/
|
|
2161
|
-
async transform(cards, _context) {
|
|
2162
|
-
const adjusted = await Promise.all(
|
|
2163
|
-
cards.map(async (card) => {
|
|
2164
|
-
const cardTags = card.tags ?? [];
|
|
2165
|
-
const priority = this.computeCardPriority(cardTags);
|
|
2166
|
-
const boostFactor = this.computeBoostFactor(priority);
|
|
2167
|
-
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2168
|
-
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2169
|
-
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2170
|
-
return {
|
|
2171
|
-
...card,
|
|
2172
|
-
score: finalScore,
|
|
2173
|
-
provenance: [
|
|
2174
|
-
...card.provenance,
|
|
2175
|
-
{
|
|
2176
|
-
strategy: "relativePriority",
|
|
2177
|
-
strategyName: this.strategyName || this.name,
|
|
2178
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2179
|
-
action,
|
|
2180
|
-
score: finalScore,
|
|
2181
|
-
reason
|
|
2182
|
-
}
|
|
2183
|
-
]
|
|
2184
|
-
};
|
|
2185
|
-
})
|
|
2186
|
-
);
|
|
2187
|
-
return adjusted;
|
|
2188
|
-
}
|
|
2189
|
-
/**
|
|
2190
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2191
|
-
*
|
|
2192
|
-
* Use transform() via Pipeline instead.
|
|
2193
|
-
*/
|
|
2194
|
-
async getWeightedCards(_limit) {
|
|
2195
|
-
throw new Error(
|
|
2196
|
-
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2197
|
-
);
|
|
2198
|
-
}
|
|
2199
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2200
|
-
async getNewCards(_n) {
|
|
2201
|
-
return [];
|
|
2202
|
-
}
|
|
2203
|
-
async getPendingReviews() {
|
|
2204
|
-
return [];
|
|
2205
|
-
}
|
|
2206
|
-
};
|
|
2207
|
-
}
|
|
2208
|
-
});
|
|
2209
|
-
|
|
2210
|
-
// src/core/navigators/srs.ts
|
|
2211
|
-
var srs_exports = {};
|
|
2212
|
-
__export(srs_exports, {
|
|
2213
|
-
default: () => SRSNavigator
|
|
2214
|
-
});
|
|
2215
|
-
import moment3 from "moment";
|
|
2216
|
-
var SRSNavigator;
|
|
2217
|
-
var init_srs = __esm({
|
|
2218
|
-
"src/core/navigators/srs.ts"() {
|
|
2219
|
-
"use strict";
|
|
2220
|
-
init_navigators();
|
|
2221
|
-
SRSNavigator = class extends ContentNavigator {
|
|
2222
|
-
/** Human-readable name for CardGenerator interface */
|
|
2223
|
-
name;
|
|
2224
|
-
constructor(user, course, strategyData) {
|
|
2225
|
-
super(user, course, strategyData);
|
|
2226
|
-
this.name = strategyData?.name || "SRS";
|
|
2227
|
-
}
|
|
2228
|
-
/**
|
|
2229
|
-
* Get review cards scored by urgency.
|
|
2230
|
-
*
|
|
2231
|
-
* Score formula combines:
|
|
2232
|
-
* - Relative overdueness: hoursOverdue / intervalHours
|
|
2233
|
-
* - Interval recency: exponential decay favoring shorter intervals
|
|
2234
|
-
*
|
|
2235
|
-
* Cards not yet due are excluded (not scored as 0).
|
|
2236
|
-
*
|
|
2237
|
-
* This method supports both the legacy signature (limit only) and the
|
|
2238
|
-
* CardGenerator interface signature (limit, context).
|
|
2239
|
-
*
|
|
2240
|
-
* @param limit - Maximum number of cards to return
|
|
2241
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
2242
|
-
*/
|
|
2243
|
-
async getWeightedCards(limit, _context) {
|
|
2244
|
-
if (!this.user || !this.course) {
|
|
2245
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
1383
|
+
async getWeightedCards(limit, _context) {
|
|
1384
|
+
if (!this.user || !this.course) {
|
|
1385
|
+
throw new Error("SRSNavigator requires user and course to be set");
|
|
2246
1386
|
}
|
|
2247
1387
|
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2248
1388
|
const now = moment3.utc();
|
|
@@ -2253,6 +1393,7 @@ var init_srs = __esm({
|
|
|
2253
1393
|
cardId: review.cardId,
|
|
2254
1394
|
courseId: review.courseId,
|
|
2255
1395
|
score,
|
|
1396
|
+
reviewID: review._id,
|
|
2256
1397
|
provenance: [
|
|
2257
1398
|
{
|
|
2258
1399
|
strategy: "srs",
|
|
@@ -2265,6 +1406,7 @@ var init_srs = __esm({
|
|
|
2265
1406
|
]
|
|
2266
1407
|
};
|
|
2267
1408
|
});
|
|
1409
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
2268
1410
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
2269
1411
|
}
|
|
2270
1412
|
/**
|
|
@@ -2280,324 +1422,127 @@ var init_srs = __esm({
|
|
|
2280
1422
|
* - 30 days (720h) → ~0.56
|
|
2281
1423
|
* - 180 days → ~0.30
|
|
2282
1424
|
*
|
|
2283
|
-
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
2284
|
-
* Result range: approximately 0.5 to 0.95
|
|
2285
|
-
*/
|
|
2286
|
-
computeUrgencyScore(review, now) {
|
|
2287
|
-
const scheduledAt = moment3.utc(review.scheduledAt);
|
|
2288
|
-
const due = moment3.utc(review.reviewTime);
|
|
2289
|
-
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
2290
|
-
const hoursOverdue = now.diff(due, "hours");
|
|
2291
|
-
const relativeOverdue = hoursOverdue / intervalHours;
|
|
2292
|
-
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
2293
|
-
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
2294
|
-
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
2295
|
-
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
2296
|
-
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
2297
|
-
return { score, reason };
|
|
2298
|
-
}
|
|
2299
|
-
/**
|
|
2300
|
-
* Get pending reviews in legacy format.
|
|
2301
|
-
*
|
|
2302
|
-
* Returns all pending reviews for the course, enriched with session item fields.
|
|
2303
|
-
*/
|
|
2304
|
-
async getPendingReviews() {
|
|
2305
|
-
if (!this.user || !this.course) {
|
|
2306
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
2307
|
-
}
|
|
2308
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2309
|
-
return reviews.map((r) => ({
|
|
2310
|
-
...r,
|
|
2311
|
-
contentSourceType: "course",
|
|
2312
|
-
contentSourceID: this.course.getCourseID(),
|
|
2313
|
-
cardID: r.cardId,
|
|
2314
|
-
courseID: r.courseId,
|
|
2315
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
2316
|
-
reviewID: r._id,
|
|
2317
|
-
status: "review"
|
|
2318
|
-
}));
|
|
2319
|
-
}
|
|
2320
|
-
/**
|
|
2321
|
-
* SRS does not generate new cards.
|
|
2322
|
-
* Use ELONavigator or another generator for new cards.
|
|
2323
|
-
*/
|
|
2324
|
-
async getNewCards(_n) {
|
|
2325
|
-
return [];
|
|
2326
|
-
}
|
|
2327
|
-
};
|
|
2328
|
-
}
|
|
2329
|
-
});
|
|
2330
|
-
|
|
2331
|
-
// src/core/navigators/userGoal.ts
|
|
2332
|
-
var userGoal_exports = {};
|
|
2333
|
-
__export(userGoal_exports, {
|
|
2334
|
-
USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
|
|
2335
|
-
});
|
|
2336
|
-
var USER_GOAL_NAVIGATOR_STUB;
|
|
2337
|
-
var init_userGoal = __esm({
|
|
2338
|
-
"src/core/navigators/userGoal.ts"() {
|
|
2339
|
-
"use strict";
|
|
2340
|
-
USER_GOAL_NAVIGATOR_STUB = true;
|
|
2341
|
-
}
|
|
2342
|
-
});
|
|
2343
|
-
|
|
2344
|
-
// import("./**/*") in src/core/navigators/index.ts
|
|
2345
|
-
var globImport;
|
|
2346
|
-
var init_ = __esm({
|
|
2347
|
-
'import("./**/*") in src/core/navigators/index.ts'() {
|
|
2348
|
-
globImport = __glob({
|
|
2349
|
-
"./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2350
|
-
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2351
|
-
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
2352
|
-
"./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2353
|
-
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2354
|
-
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2355
|
-
"./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2356
|
-
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
|
|
2357
|
-
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2358
|
-
"./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2359
|
-
"./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
|
|
2360
|
-
"./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2361
|
-
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
|
|
2362
|
-
"./inferredPreference.ts": () => Promise.resolve().then(() => (init_inferredPreference(), inferredPreference_exports)),
|
|
2363
|
-
"./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2364
|
-
"./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2365
|
-
"./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2366
|
-
"./userGoal.ts": () => Promise.resolve().then(() => (init_userGoal(), userGoal_exports))
|
|
2367
|
-
});
|
|
2368
|
-
}
|
|
2369
|
-
});
|
|
2370
|
-
|
|
2371
|
-
// src/core/navigators/index.ts
|
|
2372
|
-
var navigators_exports = {};
|
|
2373
|
-
__export(navigators_exports, {
|
|
2374
|
-
ContentNavigator: () => ContentNavigator,
|
|
2375
|
-
NavigatorRole: () => NavigatorRole,
|
|
2376
|
-
NavigatorRoles: () => NavigatorRoles,
|
|
2377
|
-
Navigators: () => Navigators,
|
|
2378
|
-
getCardOrigin: () => getCardOrigin,
|
|
2379
|
-
isFilter: () => isFilter,
|
|
2380
|
-
isGenerator: () => isGenerator
|
|
2381
|
-
});
|
|
2382
|
-
function getCardOrigin(card) {
|
|
2383
|
-
if (card.provenance.length === 0) {
|
|
2384
|
-
throw new Error("Card has no provenance - cannot determine origin");
|
|
2385
|
-
}
|
|
2386
|
-
const firstEntry = card.provenance[0];
|
|
2387
|
-
const reason = firstEntry.reason.toLowerCase();
|
|
2388
|
-
if (reason.includes("failed")) {
|
|
2389
|
-
return "failed";
|
|
2390
|
-
}
|
|
2391
|
-
if (reason.includes("review")) {
|
|
2392
|
-
return "review";
|
|
2393
|
-
}
|
|
2394
|
-
return "new";
|
|
2395
|
-
}
|
|
2396
|
-
function isGenerator(impl) {
|
|
2397
|
-
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
2398
|
-
}
|
|
2399
|
-
function isFilter(impl) {
|
|
2400
|
-
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
2401
|
-
}
|
|
2402
|
-
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
2403
|
-
var init_navigators = __esm({
|
|
2404
|
-
"src/core/navigators/index.ts"() {
|
|
2405
|
-
"use strict";
|
|
2406
|
-
init_logger();
|
|
2407
|
-
init_();
|
|
2408
|
-
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2409
|
-
Navigators2["ELO"] = "elo";
|
|
2410
|
-
Navigators2["SRS"] = "srs";
|
|
2411
|
-
Navigators2["HARDCODED"] = "hardcodedOrder";
|
|
2412
|
-
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2413
|
-
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2414
|
-
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
2415
|
-
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
2416
|
-
return Navigators2;
|
|
2417
|
-
})(Navigators || {});
|
|
2418
|
-
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
2419
|
-
NavigatorRole2["GENERATOR"] = "generator";
|
|
2420
|
-
NavigatorRole2["FILTER"] = "filter";
|
|
2421
|
-
return NavigatorRole2;
|
|
2422
|
-
})(NavigatorRole || {});
|
|
2423
|
-
NavigatorRoles = {
|
|
2424
|
-
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2425
|
-
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
2426
|
-
["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
|
|
2427
|
-
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2428
|
-
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2429
|
-
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
2430
|
-
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
2431
|
-
};
|
|
2432
|
-
ContentNavigator = class {
|
|
2433
|
-
/** User interface for this navigation session */
|
|
2434
|
-
user;
|
|
2435
|
-
/** Course interface for this navigation session */
|
|
2436
|
-
course;
|
|
2437
|
-
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
2438
|
-
strategyName;
|
|
2439
|
-
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
2440
|
-
strategyId;
|
|
2441
|
-
/**
|
|
2442
|
-
* Constructor for standard navigators.
|
|
2443
|
-
* Call this from subclass constructors to initialize common fields.
|
|
2444
|
-
*
|
|
2445
|
-
* Note: CompositeGenerator doesn't use this pattern and should call super() without args.
|
|
2446
|
-
*/
|
|
2447
|
-
constructor(user, course, strategyData) {
|
|
2448
|
-
if (user && course && strategyData) {
|
|
2449
|
-
this.user = user;
|
|
2450
|
-
this.course = course;
|
|
2451
|
-
this.strategyName = strategyData.name;
|
|
2452
|
-
this.strategyId = strategyData._id;
|
|
2453
|
-
}
|
|
2454
|
-
}
|
|
2455
|
-
// ============================================================================
|
|
2456
|
-
// STRATEGY STATE HELPERS
|
|
2457
|
-
// ============================================================================
|
|
2458
|
-
//
|
|
2459
|
-
// These methods allow strategies to persist their own state (user preferences,
|
|
2460
|
-
// learned patterns, temporal tracking) in the user database.
|
|
2461
|
-
//
|
|
2462
|
-
// ============================================================================
|
|
2463
|
-
/**
|
|
2464
|
-
* Unique key identifying this strategy for state storage.
|
|
2465
|
-
*
|
|
2466
|
-
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
2467
|
-
* Override in subclasses if multiple instances of the same strategy type
|
|
2468
|
-
* need separate state storage.
|
|
2469
|
-
*/
|
|
2470
|
-
get strategyKey() {
|
|
2471
|
-
return this.constructor.name;
|
|
2472
|
-
}
|
|
2473
|
-
/**
|
|
2474
|
-
* Get this strategy's persisted state for the current course.
|
|
2475
|
-
*
|
|
2476
|
-
* @returns The strategy's data payload, or null if no state exists
|
|
2477
|
-
* @throws Error if user or course is not initialized
|
|
2478
|
-
*/
|
|
2479
|
-
async getStrategyState() {
|
|
2480
|
-
if (!this.user || !this.course) {
|
|
2481
|
-
throw new Error(
|
|
2482
|
-
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2483
|
-
);
|
|
2484
|
-
}
|
|
2485
|
-
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
2486
|
-
}
|
|
2487
|
-
/**
|
|
2488
|
-
* Persist this strategy's state for the current course.
|
|
2489
|
-
*
|
|
2490
|
-
* @param data - The strategy's data payload to store
|
|
2491
|
-
* @throws Error if user or course is not initialized
|
|
2492
|
-
*/
|
|
2493
|
-
async putStrategyState(data) {
|
|
2494
|
-
if (!this.user || !this.course) {
|
|
2495
|
-
throw new Error(
|
|
2496
|
-
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2497
|
-
);
|
|
2498
|
-
}
|
|
2499
|
-
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
2500
|
-
}
|
|
2501
|
-
/**
|
|
2502
|
-
* Factory method to create navigator instances dynamically.
|
|
2503
|
-
*
|
|
2504
|
-
* @param user - User interface
|
|
2505
|
-
* @param course - Course interface
|
|
2506
|
-
* @param strategyData - Strategy configuration document
|
|
2507
|
-
* @returns the runtime object used to steer a study session.
|
|
2508
|
-
*/
|
|
2509
|
-
static async create(user, course, strategyData) {
|
|
2510
|
-
const implementingClass = strategyData.implementingClass;
|
|
2511
|
-
let NavigatorImpl;
|
|
2512
|
-
const variations = [".ts", ".js", ""];
|
|
2513
|
-
for (const ext of variations) {
|
|
2514
|
-
try {
|
|
2515
|
-
const module = await globImport(`./${implementingClass}${ext}`);
|
|
2516
|
-
NavigatorImpl = module.default;
|
|
2517
|
-
break;
|
|
2518
|
-
} catch (e) {
|
|
2519
|
-
logger.debug(`Failed to load with extension ${ext}:`, e);
|
|
2520
|
-
}
|
|
2521
|
-
}
|
|
2522
|
-
if (!NavigatorImpl) {
|
|
2523
|
-
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
2524
|
-
}
|
|
2525
|
-
return new NavigatorImpl(user, course, strategyData);
|
|
2526
|
-
}
|
|
2527
|
-
/**
|
|
2528
|
-
* Get cards with suitability scores and provenance trails.
|
|
2529
|
-
*
|
|
2530
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
2531
|
-
*
|
|
2532
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
2533
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
2534
|
-
* documenting how strategies contributed to the final score.
|
|
2535
|
-
*
|
|
2536
|
-
* ## For Generators
|
|
2537
|
-
* Override this method to generate candidates and compute scores based on
|
|
2538
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2539
|
-
* initial provenance entry with action='generated'.
|
|
2540
|
-
*
|
|
2541
|
-
* ## Default Implementation
|
|
2542
|
-
* The base class provides a backward-compatible default that:
|
|
2543
|
-
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
2544
|
-
* 2. Assigns score=1.0 to all cards
|
|
2545
|
-
* 3. Creates minimal provenance from legacy methods
|
|
2546
|
-
* 4. Returns combined results up to limit
|
|
2547
|
-
*
|
|
2548
|
-
* This allows existing strategies to work without modification while
|
|
2549
|
-
* new strategies can override with proper scoring and provenance.
|
|
2550
|
-
*
|
|
2551
|
-
* @param limit - Maximum cards to return
|
|
2552
|
-
* @returns Cards sorted by score descending, with provenance trails
|
|
1425
|
+
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
1426
|
+
* Result range: approximately 0.5 to 0.95
|
|
2553
1427
|
*/
|
|
2554
|
-
|
|
2555
|
-
const
|
|
2556
|
-
const
|
|
2557
|
-
const
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2567
|
-
action: "generated",
|
|
2568
|
-
score: 1,
|
|
2569
|
-
reason: "Generated via legacy getNewCards(), new card"
|
|
2570
|
-
}
|
|
2571
|
-
]
|
|
2572
|
-
})),
|
|
2573
|
-
...reviews.map((r) => ({
|
|
2574
|
-
cardId: r.cardID,
|
|
2575
|
-
courseId: r.courseID,
|
|
2576
|
-
score: 1,
|
|
2577
|
-
provenance: [
|
|
2578
|
-
{
|
|
2579
|
-
strategy: "legacy",
|
|
2580
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2581
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2582
|
-
action: "generated",
|
|
2583
|
-
score: 1,
|
|
2584
|
-
reason: "Generated via legacy getPendingReviews(), review"
|
|
2585
|
-
}
|
|
2586
|
-
]
|
|
2587
|
-
}))
|
|
2588
|
-
];
|
|
2589
|
-
return weighted.slice(0, limit);
|
|
1428
|
+
computeUrgencyScore(review, now) {
|
|
1429
|
+
const scheduledAt = moment3.utc(review.scheduledAt);
|
|
1430
|
+
const due = moment3.utc(review.reviewTime);
|
|
1431
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
1432
|
+
const hoursOverdue = now.diff(due, "hours");
|
|
1433
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
1434
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1435
|
+
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1436
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1437
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
1438
|
+
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
1439
|
+
return { score, reason };
|
|
2590
1440
|
}
|
|
2591
1441
|
};
|
|
2592
1442
|
}
|
|
2593
1443
|
});
|
|
2594
1444
|
|
|
1445
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1446
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1447
|
+
const normalizedDistance = distance / halfLife;
|
|
1448
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1449
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1450
|
+
}
|
|
1451
|
+
function createEloDistanceFilter(config) {
|
|
1452
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1453
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1454
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1455
|
+
return {
|
|
1456
|
+
name: "ELO Distance Filter",
|
|
1457
|
+
async transform(cards, context) {
|
|
1458
|
+
const { course, userElo } = context;
|
|
1459
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1460
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1461
|
+
return cards.map((card, i) => {
|
|
1462
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1463
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1464
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1465
|
+
const newScore = card.score * multiplier;
|
|
1466
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1467
|
+
return {
|
|
1468
|
+
...card,
|
|
1469
|
+
score: newScore,
|
|
1470
|
+
provenance: [
|
|
1471
|
+
...card.provenance,
|
|
1472
|
+
{
|
|
1473
|
+
strategy: "eloDistance",
|
|
1474
|
+
strategyName: "ELO Distance Filter",
|
|
1475
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1476
|
+
action,
|
|
1477
|
+
score: newScore,
|
|
1478
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1479
|
+
}
|
|
1480
|
+
]
|
|
1481
|
+
};
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1487
|
+
var init_eloDistance = __esm({
|
|
1488
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1489
|
+
"use strict";
|
|
1490
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1491
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1492
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1493
|
+
}
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
// src/core/navigators/defaults.ts
|
|
1497
|
+
function createDefaultEloStrategy(courseId) {
|
|
1498
|
+
return {
|
|
1499
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1500
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1501
|
+
name: "ELO (default)",
|
|
1502
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
1503
|
+
implementingClass: "elo" /* ELO */,
|
|
1504
|
+
course: courseId,
|
|
1505
|
+
serializedData: ""
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
function createDefaultSrsStrategy(courseId) {
|
|
1509
|
+
return {
|
|
1510
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
1511
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1512
|
+
name: "SRS (default)",
|
|
1513
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
1514
|
+
implementingClass: "srs" /* SRS */,
|
|
1515
|
+
course: courseId,
|
|
1516
|
+
serializedData: ""
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
function createDefaultPipeline(user, course) {
|
|
1520
|
+
const courseId = course.getCourseID();
|
|
1521
|
+
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
1522
|
+
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
1523
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
1524
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
1525
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
1526
|
+
}
|
|
1527
|
+
var init_defaults = __esm({
|
|
1528
|
+
"src/core/navigators/defaults.ts"() {
|
|
1529
|
+
"use strict";
|
|
1530
|
+
init_navigators();
|
|
1531
|
+
init_Pipeline();
|
|
1532
|
+
init_CompositeGenerator();
|
|
1533
|
+
init_elo();
|
|
1534
|
+
init_srs();
|
|
1535
|
+
init_eloDistance();
|
|
1536
|
+
init_types_legacy();
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
|
|
2595
1540
|
// src/impl/couch/courseDB.ts
|
|
2596
1541
|
import {
|
|
2597
1542
|
EloToNumber,
|
|
2598
1543
|
Status,
|
|
2599
1544
|
blankCourseElo as blankCourseElo2,
|
|
2600
|
-
toCourseElo as
|
|
1545
|
+
toCourseElo as toCourseElo4
|
|
2601
1546
|
} from "@vue-skuilder/common";
|
|
2602
1547
|
function randIntWeightedTowardZero(n) {
|
|
2603
1548
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -2686,12 +1631,8 @@ var init_courseDB = __esm({
|
|
|
2686
1631
|
init_courseAPI();
|
|
2687
1632
|
init_courseLookupDB();
|
|
2688
1633
|
init_navigators();
|
|
2689
|
-
init_Pipeline();
|
|
2690
1634
|
init_PipelineAssembler();
|
|
2691
|
-
|
|
2692
|
-
init_elo();
|
|
2693
|
-
init_srs();
|
|
2694
|
-
init_eloDistance();
|
|
1635
|
+
init_defaults();
|
|
2695
1636
|
CourseDB = class {
|
|
2696
1637
|
// private log(msg: string): void {
|
|
2697
1638
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
@@ -2758,7 +1699,7 @@ var init_courseDB = __esm({
|
|
|
2758
1699
|
docs.rows.forEach((r) => {
|
|
2759
1700
|
if (isSuccessRow(r)) {
|
|
2760
1701
|
if (r.doc && r.doc.elo) {
|
|
2761
|
-
ret.push(
|
|
1702
|
+
ret.push(toCourseElo4(r.doc.elo));
|
|
2762
1703
|
} else {
|
|
2763
1704
|
logger.warn("no elo data for card: " + r.id);
|
|
2764
1705
|
ret.push(blankCourseElo2());
|
|
@@ -3060,7 +2001,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3060
2001
|
logger.debug(
|
|
3061
2002
|
"[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
3062
2003
|
);
|
|
3063
|
-
return
|
|
2004
|
+
return createDefaultPipeline(user, this);
|
|
3064
2005
|
}
|
|
3065
2006
|
const assembler = new PipelineAssembler();
|
|
3066
2007
|
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
@@ -3073,7 +2014,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3073
2014
|
}
|
|
3074
2015
|
if (!pipeline) {
|
|
3075
2016
|
logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
|
|
3076
|
-
return
|
|
2017
|
+
return createDefaultPipeline(user, this);
|
|
3077
2018
|
}
|
|
3078
2019
|
logger.debug(
|
|
3079
2020
|
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
@@ -3084,69 +2025,12 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3084
2025
|
throw e;
|
|
3085
2026
|
}
|
|
3086
2027
|
}
|
|
3087
|
-
makeDefaultEloStrategy() {
|
|
3088
|
-
return {
|
|
3089
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
3090
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3091
|
-
name: "ELO (default)",
|
|
3092
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
3093
|
-
implementingClass: "elo" /* ELO */,
|
|
3094
|
-
course: this.id,
|
|
3095
|
-
serializedData: ""
|
|
3096
|
-
};
|
|
3097
|
-
}
|
|
3098
|
-
makeDefaultSrsStrategy() {
|
|
3099
|
-
return {
|
|
3100
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3101
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3102
|
-
name: "SRS (default)",
|
|
3103
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
3104
|
-
implementingClass: "srs" /* SRS */,
|
|
3105
|
-
course: this.id,
|
|
3106
|
-
serializedData: ""
|
|
3107
|
-
};
|
|
3108
|
-
}
|
|
3109
|
-
/**
|
|
3110
|
-
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
3111
|
-
*
|
|
3112
|
-
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
3113
|
-
* - ELO generator: scores new cards by skill proximity
|
|
3114
|
-
* - SRS generator: scores reviews by overdueness and interval recency
|
|
3115
|
-
* - ELO distance filter: penalizes cards far from user's current level
|
|
3116
|
-
*/
|
|
3117
|
-
createDefaultPipeline(user) {
|
|
3118
|
-
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
3119
|
-
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
3120
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3121
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
3122
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
3123
|
-
}
|
|
3124
2028
|
////////////////////////////////////
|
|
3125
2029
|
// END NavigationStrategyManager implementation
|
|
3126
2030
|
////////////////////////////////////
|
|
3127
2031
|
////////////////////////////////////
|
|
3128
2032
|
// StudyContentSource implementation
|
|
3129
2033
|
////////////////////////////////////
|
|
3130
|
-
async getNewCards(limit = 99) {
|
|
3131
|
-
const u = await this._getCurrentUser();
|
|
3132
|
-
try {
|
|
3133
|
-
const navigator = await this.createNavigator(u);
|
|
3134
|
-
return navigator.getNewCards(limit);
|
|
3135
|
-
} catch (e) {
|
|
3136
|
-
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
3137
|
-
throw e;
|
|
3138
|
-
}
|
|
3139
|
-
}
|
|
3140
|
-
async getPendingReviews() {
|
|
3141
|
-
const u = await this._getCurrentUser();
|
|
3142
|
-
try {
|
|
3143
|
-
const navigator = await this.createNavigator(u);
|
|
3144
|
-
return navigator.getPendingReviews();
|
|
3145
|
-
} catch (e) {
|
|
3146
|
-
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
3147
|
-
throw e;
|
|
3148
|
-
}
|
|
3149
|
-
}
|
|
3150
2034
|
/**
|
|
3151
2035
|
* Get cards with suitability scores for presentation.
|
|
3152
2036
|
*
|
|
@@ -3385,79 +2269,27 @@ var init_classroomDB2 = __esm({
|
|
|
3385
2269
|
setChangeFcn(f) {
|
|
3386
2270
|
void this.userMessages.on("change", f);
|
|
3387
2271
|
}
|
|
3388
|
-
async getPendingReviews() {
|
|
3389
|
-
const u = this._user;
|
|
3390
|
-
return (await u.getPendingReviews()).filter((r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id).map((r) => {
|
|
3391
|
-
return {
|
|
3392
|
-
...r,
|
|
3393
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
3394
|
-
courseID: r.courseId,
|
|
3395
|
-
cardID: r.cardId,
|
|
3396
|
-
contentSourceType: "classroom",
|
|
3397
|
-
contentSourceID: this._id,
|
|
3398
|
-
reviewID: r._id,
|
|
3399
|
-
status: "review"
|
|
3400
|
-
};
|
|
3401
|
-
});
|
|
3402
|
-
}
|
|
3403
|
-
async getNewCards() {
|
|
3404
|
-
const activeCards = await this._user.getActiveCards();
|
|
3405
|
-
const now = moment4.utc();
|
|
3406
|
-
const assigned = await this.getAssignedContent();
|
|
3407
|
-
const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
3408
|
-
logger.info(`Due content: ${JSON.stringify(due)}`);
|
|
3409
|
-
let ret = [];
|
|
3410
|
-
for (let i = 0; i < due.length; i++) {
|
|
3411
|
-
const content = due[i];
|
|
3412
|
-
if (content.type === "course") {
|
|
3413
|
-
const db = new CourseDB(content.courseID, async () => this._user);
|
|
3414
|
-
ret = ret.concat(await db.getNewCards());
|
|
3415
|
-
} else if (content.type === "tag") {
|
|
3416
|
-
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
3417
|
-
ret = ret.concat(
|
|
3418
|
-
tagDoc.taggedCards.map((c) => {
|
|
3419
|
-
return {
|
|
3420
|
-
courseID: content.courseID,
|
|
3421
|
-
cardID: c,
|
|
3422
|
-
qualifiedID: `${content.courseID}-${c}`,
|
|
3423
|
-
contentSourceType: "classroom",
|
|
3424
|
-
contentSourceID: this._id,
|
|
3425
|
-
status: "new"
|
|
3426
|
-
};
|
|
3427
|
-
})
|
|
3428
|
-
);
|
|
3429
|
-
} else if (content.type === "card") {
|
|
3430
|
-
ret.push(await getCourseDB2(content.courseID).get(content.cardID));
|
|
3431
|
-
}
|
|
3432
|
-
}
|
|
3433
|
-
logger.info(
|
|
3434
|
-
`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
|
|
3435
|
-
);
|
|
3436
|
-
return ret.filter((c) => {
|
|
3437
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
3438
|
-
return false;
|
|
3439
|
-
} else {
|
|
3440
|
-
return true;
|
|
3441
|
-
}
|
|
3442
|
-
});
|
|
3443
|
-
}
|
|
3444
2272
|
/**
|
|
3445
2273
|
* Get cards with suitability scores for presentation.
|
|
3446
2274
|
*
|
|
3447
|
-
*
|
|
3448
|
-
*
|
|
3449
|
-
* support pluggable navigation strategies.
|
|
2275
|
+
* Gathers new cards from assigned content (courses, tags, cards) and
|
|
2276
|
+
* pending reviews scheduled for this classroom. Assigns score=1.0 to all.
|
|
3450
2277
|
*
|
|
3451
2278
|
* @param limit - Maximum number of cards to return
|
|
3452
2279
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3453
2280
|
*/
|
|
3454
2281
|
async getWeightedCards(limit) {
|
|
3455
|
-
const
|
|
3456
|
-
const
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
2282
|
+
const weighted = [];
|
|
2283
|
+
const allUserReviews = await this._user.getPendingReviews();
|
|
2284
|
+
const classroomReviews = allUserReviews.filter(
|
|
2285
|
+
(r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id
|
|
2286
|
+
);
|
|
2287
|
+
for (const r of classroomReviews) {
|
|
2288
|
+
weighted.push({
|
|
2289
|
+
cardId: r.cardId,
|
|
2290
|
+
courseId: r.courseId,
|
|
3460
2291
|
score: 1,
|
|
2292
|
+
reviewID: r._id,
|
|
3461
2293
|
provenance: [
|
|
3462
2294
|
{
|
|
3463
2295
|
strategy: "classroom",
|
|
@@ -3465,27 +2297,84 @@ var init_classroomDB2 = __esm({
|
|
|
3465
2297
|
strategyId: "CLASSROOM",
|
|
3466
2298
|
action: "generated",
|
|
3467
2299
|
score: 1,
|
|
3468
|
-
reason: "Classroom
|
|
2300
|
+
reason: "Classroom scheduled review"
|
|
3469
2301
|
}
|
|
3470
2302
|
]
|
|
3471
|
-
})
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
const activeCards = await this._user.getActiveCards();
|
|
2306
|
+
const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
2307
|
+
const now = moment4.utc();
|
|
2308
|
+
const assigned = await this.getAssignedContent();
|
|
2309
|
+
const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
2310
|
+
logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
|
|
2311
|
+
for (const content of due) {
|
|
2312
|
+
if (content.type === "course") {
|
|
2313
|
+
const db = new CourseDB(content.courseID, async () => this._user);
|
|
2314
|
+
const courseCards = await db.getWeightedCards(limit);
|
|
2315
|
+
for (const card of courseCards) {
|
|
2316
|
+
if (!activeCardIds.has(card.cardId)) {
|
|
2317
|
+
weighted.push({
|
|
2318
|
+
...card,
|
|
2319
|
+
provenance: [
|
|
2320
|
+
...card.provenance,
|
|
2321
|
+
{
|
|
2322
|
+
strategy: "classroom",
|
|
2323
|
+
strategyName: "Classroom",
|
|
2324
|
+
strategyId: "CLASSROOM",
|
|
2325
|
+
action: "passed",
|
|
2326
|
+
score: card.score,
|
|
2327
|
+
reason: `Assigned via classroom from course ${content.courseID}`
|
|
2328
|
+
}
|
|
2329
|
+
]
|
|
2330
|
+
});
|
|
3484
2331
|
}
|
|
3485
|
-
|
|
3486
|
-
})
|
|
3487
|
-
|
|
3488
|
-
|
|
2332
|
+
}
|
|
2333
|
+
} else if (content.type === "tag") {
|
|
2334
|
+
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
2335
|
+
for (const cardId of tagDoc.taggedCards) {
|
|
2336
|
+
if (!activeCardIds.has(cardId)) {
|
|
2337
|
+
weighted.push({
|
|
2338
|
+
cardId,
|
|
2339
|
+
courseId: content.courseID,
|
|
2340
|
+
score: 1,
|
|
2341
|
+
provenance: [
|
|
2342
|
+
{
|
|
2343
|
+
strategy: "classroom",
|
|
2344
|
+
strategyName: "Classroom",
|
|
2345
|
+
strategyId: "CLASSROOM",
|
|
2346
|
+
action: "generated",
|
|
2347
|
+
score: 1,
|
|
2348
|
+
reason: `Classroom assigned tag: ${content.tagID}, new card`
|
|
2349
|
+
}
|
|
2350
|
+
]
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
} else if (content.type === "card") {
|
|
2355
|
+
if (!activeCardIds.has(content.cardID)) {
|
|
2356
|
+
weighted.push({
|
|
2357
|
+
cardId: content.cardID,
|
|
2358
|
+
courseId: content.courseID,
|
|
2359
|
+
score: 1,
|
|
2360
|
+
provenance: [
|
|
2361
|
+
{
|
|
2362
|
+
strategy: "classroom",
|
|
2363
|
+
strategyName: "Classroom",
|
|
2364
|
+
strategyId: "CLASSROOM",
|
|
2365
|
+
action: "generated",
|
|
2366
|
+
score: 1,
|
|
2367
|
+
reason: "Classroom assigned card, new card"
|
|
2368
|
+
}
|
|
2369
|
+
]
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
logger.info(
|
|
2375
|
+
`[StudentClassroomDB] New cards from classroom ${this._cfg.name}: ${weighted.length} total (reviews + new)`
|
|
2376
|
+
);
|
|
2377
|
+
return weighted.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
3489
2378
|
}
|
|
3490
2379
|
};
|
|
3491
2380
|
}
|
|
@@ -4688,108 +3577,71 @@ var init_TagFilteredContentSource = __esm({
|
|
|
4688
3577
|
return finalCardIds;
|
|
4689
3578
|
}
|
|
4690
3579
|
/**
|
|
4691
|
-
*
|
|
3580
|
+
* Get cards with suitability scores for presentation.
|
|
3581
|
+
*
|
|
3582
|
+
* Filters cards by tag inclusion/exclusion and assigns score=1.0 to all.
|
|
3583
|
+
* TagFilteredContentSource does not currently support pluggable navigation
|
|
3584
|
+
* strategies - it returns flat-scored candidates.
|
|
3585
|
+
*
|
|
3586
|
+
* @param limit - Maximum number of cards to return
|
|
3587
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
4692
3588
|
*/
|
|
4693
|
-
async
|
|
3589
|
+
async getWeightedCards(limit) {
|
|
4694
3590
|
if (!hasActiveFilter(this.filter)) {
|
|
4695
|
-
logger.warn("[TagFilteredContentSource]
|
|
3591
|
+
logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
|
|
4696
3592
|
return [];
|
|
4697
3593
|
}
|
|
4698
3594
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
4699
3595
|
const activeCards = await this.user.getActiveCards();
|
|
4700
3596
|
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
4701
|
-
const
|
|
3597
|
+
const newCardWeighted = [];
|
|
4702
3598
|
for (const cardId of eligibleCardIds) {
|
|
4703
3599
|
if (!activeCardIds.has(cardId)) {
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
3600
|
+
newCardWeighted.push({
|
|
3601
|
+
cardId,
|
|
3602
|
+
courseId: this.courseId,
|
|
3603
|
+
score: 1,
|
|
3604
|
+
provenance: [
|
|
3605
|
+
{
|
|
3606
|
+
strategy: "tagFilter",
|
|
3607
|
+
strategyName: "Tag Filter",
|
|
3608
|
+
strategyId: "TAG_FILTER",
|
|
3609
|
+
action: "generated",
|
|
3610
|
+
score: 1,
|
|
3611
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
3612
|
+
}
|
|
3613
|
+
]
|
|
4710
3614
|
});
|
|
4711
3615
|
}
|
|
4712
|
-
if (
|
|
3616
|
+
if (newCardWeighted.length >= limit) {
|
|
4713
3617
|
break;
|
|
4714
3618
|
}
|
|
4715
3619
|
}
|
|
4716
|
-
logger.info(
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
/**
|
|
4720
|
-
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
4721
|
-
*/
|
|
4722
|
-
async getPendingReviews() {
|
|
4723
|
-
if (!hasActiveFilter(this.filter)) {
|
|
4724
|
-
logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
|
|
4725
|
-
return [];
|
|
4726
|
-
}
|
|
4727
|
-
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
3620
|
+
logger.info(
|
|
3621
|
+
`[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
|
|
3622
|
+
);
|
|
4728
3623
|
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
4729
|
-
const filteredReviews = allReviews.filter((review) =>
|
|
4730
|
-
return eligibleCardIds.has(review.cardId);
|
|
4731
|
-
});
|
|
3624
|
+
const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
|
|
4732
3625
|
logger.info(
|
|
4733
3626
|
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
|
|
4734
3627
|
);
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
contentSourceType: "course",
|
|
4740
|
-
contentSourceID: this.courseId,
|
|
3628
|
+
const reviewWeighted = filteredReviews.map((r) => ({
|
|
3629
|
+
cardId: r.cardId,
|
|
3630
|
+
courseId: r.courseId,
|
|
3631
|
+
score: 1,
|
|
4741
3632
|
reviewID: r._id,
|
|
4742
|
-
|
|
3633
|
+
provenance: [
|
|
3634
|
+
{
|
|
3635
|
+
strategy: "tagFilter",
|
|
3636
|
+
strategyName: "Tag Filter",
|
|
3637
|
+
strategyId: "TAG_FILTER",
|
|
3638
|
+
action: "generated",
|
|
3639
|
+
score: 1,
|
|
3640
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
3641
|
+
}
|
|
3642
|
+
]
|
|
4743
3643
|
}));
|
|
4744
|
-
|
|
4745
|
-
/**
|
|
4746
|
-
* Get cards with suitability scores for presentation.
|
|
4747
|
-
*
|
|
4748
|
-
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
4749
|
-
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
4750
|
-
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
4751
|
-
*
|
|
4752
|
-
* @param limit - Maximum number of cards to return
|
|
4753
|
-
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
4754
|
-
*/
|
|
4755
|
-
async getWeightedCards(limit) {
|
|
4756
|
-
const [newCards, reviews] = await Promise.all([
|
|
4757
|
-
this.getNewCards(limit),
|
|
4758
|
-
this.getPendingReviews()
|
|
4759
|
-
]);
|
|
4760
|
-
const weighted = [
|
|
4761
|
-
...reviews.map((r) => ({
|
|
4762
|
-
cardId: r.cardID,
|
|
4763
|
-
courseId: r.courseID,
|
|
4764
|
-
score: 1,
|
|
4765
|
-
provenance: [
|
|
4766
|
-
{
|
|
4767
|
-
strategy: "tagFilter",
|
|
4768
|
-
strategyName: "Tag Filter",
|
|
4769
|
-
strategyId: "TAG_FILTER",
|
|
4770
|
-
action: "generated",
|
|
4771
|
-
score: 1,
|
|
4772
|
-
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
4773
|
-
}
|
|
4774
|
-
]
|
|
4775
|
-
})),
|
|
4776
|
-
...newCards.map((c) => ({
|
|
4777
|
-
cardId: c.cardID,
|
|
4778
|
-
courseId: c.courseID,
|
|
4779
|
-
score: 1,
|
|
4780
|
-
provenance: [
|
|
4781
|
-
{
|
|
4782
|
-
strategy: "tagFilter",
|
|
4783
|
-
strategyName: "Tag Filter",
|
|
4784
|
-
strategyId: "TAG_FILTER",
|
|
4785
|
-
action: "generated",
|
|
4786
|
-
score: 1,
|
|
4787
|
-
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
4788
|
-
}
|
|
4789
|
-
]
|
|
4790
|
-
}))
|
|
4791
|
-
];
|
|
4792
|
-
return weighted.slice(0, limit);
|
|
3644
|
+
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
4793
3645
|
}
|
|
4794
3646
|
/**
|
|
4795
3647
|
* Clears the cached resolved card IDs.
|
|
@@ -5011,7 +3863,7 @@ var init_cardProcessor = __esm({
|
|
|
5011
3863
|
});
|
|
5012
3864
|
|
|
5013
3865
|
// src/core/bulkImport/types.ts
|
|
5014
|
-
var
|
|
3866
|
+
var init_types = __esm({
|
|
5015
3867
|
"src/core/bulkImport/types.ts"() {
|
|
5016
3868
|
"use strict";
|
|
5017
3869
|
}
|
|
@@ -5022,7 +3874,7 @@ var init_bulkImport = __esm({
|
|
|
5022
3874
|
"src/core/bulkImport/index.ts"() {
|
|
5023
3875
|
"use strict";
|
|
5024
3876
|
init_cardProcessor();
|
|
5025
|
-
|
|
3877
|
+
init_types();
|
|
5026
3878
|
}
|
|
5027
3879
|
});
|
|
5028
3880
|
|