@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.js
CHANGED
|
@@ -5,11 +5,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __glob = (map) => (path2) => {
|
|
9
|
-
var fn = map[path2];
|
|
10
|
-
if (fn) return fn();
|
|
11
|
-
throw new Error("Module not found in bundle: " + path2);
|
|
12
|
-
};
|
|
13
8
|
var __esm = (fn, res) => function __init() {
|
|
14
9
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
15
10
|
};
|
|
@@ -188,6 +183,9 @@ var init_pouchdb_setup = __esm({
|
|
|
188
183
|
import_pouchdb_authentication = __toESM(require("@nilock2/pouchdb-authentication"), 1);
|
|
189
184
|
import_pouchdb.default.plugin(import_pouchdb_find.default);
|
|
190
185
|
import_pouchdb.default.plugin(import_pouchdb_authentication.default);
|
|
186
|
+
if (typeof import_pouchdb.default.debug !== "undefined") {
|
|
187
|
+
import_pouchdb.default.debug.disable();
|
|
188
|
+
}
|
|
191
189
|
import_pouchdb.default.defaults({
|
|
192
190
|
// ajax: {
|
|
193
191
|
// timeout: 60000,
|
|
@@ -197,14 +195,6 @@ var init_pouchdb_setup = __esm({
|
|
|
197
195
|
}
|
|
198
196
|
});
|
|
199
197
|
|
|
200
|
-
// src/util/tuiLogger.ts
|
|
201
|
-
var init_tuiLogger = __esm({
|
|
202
|
-
"src/util/tuiLogger.ts"() {
|
|
203
|
-
"use strict";
|
|
204
|
-
init_dataDirectory();
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
|
|
208
198
|
// src/util/dataDirectory.ts
|
|
209
199
|
function getAppDataDirectory() {
|
|
210
200
|
if (ENV.LOCAL_STORAGE_PREFIX) {
|
|
@@ -222,7 +212,7 @@ var init_dataDirectory = __esm({
|
|
|
222
212
|
"use strict";
|
|
223
213
|
path = __toESM(require("path"), 1);
|
|
224
214
|
os = __toESM(require("os"), 1);
|
|
225
|
-
|
|
215
|
+
init_logger();
|
|
226
216
|
init_factory();
|
|
227
217
|
}
|
|
228
218
|
});
|
|
@@ -711,195 +701,187 @@ var init_courseLookupDB = __esm({
|
|
|
711
701
|
}
|
|
712
702
|
});
|
|
713
703
|
|
|
714
|
-
// src/core/navigators/
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
704
|
+
// src/core/navigators/index.ts
|
|
705
|
+
function getCardOrigin(card) {
|
|
706
|
+
if (card.provenance.length === 0) {
|
|
707
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
708
|
+
}
|
|
709
|
+
const firstEntry = card.provenance[0];
|
|
710
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
711
|
+
if (reason.includes("failed")) {
|
|
712
|
+
return "failed";
|
|
713
|
+
}
|
|
714
|
+
if (reason.includes("review")) {
|
|
715
|
+
return "review";
|
|
716
|
+
}
|
|
717
|
+
return "new";
|
|
718
|
+
}
|
|
719
|
+
function isGenerator(impl) {
|
|
720
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
721
|
+
}
|
|
722
|
+
function isFilter(impl) {
|
|
723
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
724
|
+
}
|
|
725
|
+
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
726
|
+
var init_navigators = __esm({
|
|
727
|
+
"src/core/navigators/index.ts"() {
|
|
723
728
|
"use strict";
|
|
724
|
-
init_navigators();
|
|
725
729
|
init_logger();
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
730
|
+
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
731
|
+
Navigators2["ELO"] = "elo";
|
|
732
|
+
Navigators2["SRS"] = "srs";
|
|
733
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
734
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
735
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
736
|
+
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
737
|
+
return Navigators2;
|
|
738
|
+
})(Navigators || {});
|
|
739
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
740
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
741
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
742
|
+
return NavigatorRole2;
|
|
743
|
+
})(NavigatorRole || {});
|
|
744
|
+
NavigatorRoles = {
|
|
745
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
746
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
747
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
748
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
749
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
750
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
751
|
+
};
|
|
752
|
+
ContentNavigator = class {
|
|
753
|
+
/** User interface for this navigation session */
|
|
754
|
+
user;
|
|
755
|
+
/** Course interface for this navigation session */
|
|
756
|
+
course;
|
|
757
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
758
|
+
strategyName;
|
|
759
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
760
|
+
strategyId;
|
|
750
761
|
/**
|
|
751
|
-
*
|
|
762
|
+
* Constructor for standard navigators.
|
|
763
|
+
* Call this from subclass constructors to initialize common fields.
|
|
752
764
|
*
|
|
753
|
-
*
|
|
765
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
766
|
+
* user/course fields directly if needed.
|
|
754
767
|
*/
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
)
|
|
759
|
-
|
|
768
|
+
constructor(user, course, strategyData) {
|
|
769
|
+
this.user = user;
|
|
770
|
+
this.course = course;
|
|
771
|
+
if (strategyData) {
|
|
772
|
+
this.strategyName = strategyData.name;
|
|
773
|
+
this.strategyId = strategyData._id;
|
|
774
|
+
}
|
|
760
775
|
}
|
|
776
|
+
// ============================================================================
|
|
777
|
+
// STRATEGY STATE HELPERS
|
|
778
|
+
// ============================================================================
|
|
779
|
+
//
|
|
780
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
781
|
+
// learned patterns, temporal tracking) in the user database.
|
|
782
|
+
//
|
|
783
|
+
// ============================================================================
|
|
761
784
|
/**
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
* Cards appearing in multiple generators receive a score boost.
|
|
765
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
766
|
-
*
|
|
767
|
-
* This method supports both the legacy signature (limit only) and the
|
|
768
|
-
* CardGenerator interface signature (limit, context).
|
|
785
|
+
* Unique key identifying this strategy for state storage.
|
|
769
786
|
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
787
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
788
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
789
|
+
* need separate state storage.
|
|
772
790
|
*/
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
776
|
-
);
|
|
777
|
-
const byCardId = /* @__PURE__ */ new Map();
|
|
778
|
-
for (const cards of results) {
|
|
779
|
-
for (const card of cards) {
|
|
780
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
781
|
-
existing.push(card);
|
|
782
|
-
byCardId.set(card.cardId, existing);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
const merged = [];
|
|
786
|
-
for (const [, cards] of byCardId) {
|
|
787
|
-
const aggregatedScore = this.aggregateScores(cards);
|
|
788
|
-
const finalScore = Math.min(1, aggregatedScore);
|
|
789
|
-
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
790
|
-
const initialScore = cards[0].score;
|
|
791
|
-
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
792
|
-
const reason = this.buildAggregationReason(cards, finalScore);
|
|
793
|
-
merged.push({
|
|
794
|
-
...cards[0],
|
|
795
|
-
score: finalScore,
|
|
796
|
-
provenance: [
|
|
797
|
-
...mergedProvenance,
|
|
798
|
-
{
|
|
799
|
-
strategy: "composite",
|
|
800
|
-
strategyName: "Composite Generator",
|
|
801
|
-
strategyId: "COMPOSITE_GENERATOR",
|
|
802
|
-
action,
|
|
803
|
-
score: finalScore,
|
|
804
|
-
reason
|
|
805
|
-
}
|
|
806
|
-
]
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
791
|
+
get strategyKey() {
|
|
792
|
+
return this.constructor.name;
|
|
810
793
|
}
|
|
811
794
|
/**
|
|
812
|
-
*
|
|
795
|
+
* Get this strategy's persisted state for the current course.
|
|
796
|
+
*
|
|
797
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
798
|
+
* @throws Error if user or course is not initialized
|
|
813
799
|
*/
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
821
|
-
switch (this.aggregationMode) {
|
|
822
|
-
case "max" /* MAX */:
|
|
823
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
824
|
-
case "average" /* AVERAGE */:
|
|
825
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
826
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
827
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
828
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
829
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
830
|
-
}
|
|
831
|
-
default:
|
|
832
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
800
|
+
async getStrategyState() {
|
|
801
|
+
if (!this.user || !this.course) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
804
|
+
);
|
|
833
805
|
}
|
|
806
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
834
807
|
}
|
|
835
808
|
/**
|
|
836
|
-
*
|
|
809
|
+
* Persist this strategy's state for the current course.
|
|
810
|
+
*
|
|
811
|
+
* @param data - The strategy's data payload to store
|
|
812
|
+
* @throws Error if user or course is not initialized
|
|
837
813
|
*/
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
case "average" /* AVERAGE */:
|
|
844
|
-
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
845
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
846
|
-
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
847
|
-
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
848
|
-
return avg * frequencyBoost;
|
|
849
|
-
}
|
|
850
|
-
default:
|
|
851
|
-
return scores[0];
|
|
814
|
+
async putStrategyState(data) {
|
|
815
|
+
if (!this.user || !this.course) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
818
|
+
);
|
|
852
819
|
}
|
|
820
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
853
821
|
}
|
|
854
822
|
/**
|
|
855
|
-
*
|
|
823
|
+
* Factory method to create navigator instances dynamically.
|
|
824
|
+
*
|
|
825
|
+
* @param user - User interface
|
|
826
|
+
* @param course - Course interface
|
|
827
|
+
* @param strategyData - Strategy configuration document
|
|
828
|
+
* @returns the runtime object used to steer a study session.
|
|
856
829
|
*/
|
|
857
|
-
async
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
const
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
830
|
+
static async create(user, course, strategyData) {
|
|
831
|
+
const implementingClass = strategyData.implementingClass;
|
|
832
|
+
let NavigatorImpl;
|
|
833
|
+
const variations = [".ts", ".js", ""];
|
|
834
|
+
const dirs = ["filters", "generators"];
|
|
835
|
+
for (const ext of variations) {
|
|
836
|
+
for (const dir of dirs) {
|
|
837
|
+
const loadFrom = `./${dir}/${implementingClass}${ext}`;
|
|
838
|
+
try {
|
|
839
|
+
const module2 = await import(loadFrom);
|
|
840
|
+
NavigatorImpl = module2.default;
|
|
841
|
+
break;
|
|
842
|
+
} catch (e) {
|
|
843
|
+
logger.debug(`Failed to load extension from ${loadFrom}:`, e);
|
|
869
844
|
}
|
|
870
845
|
}
|
|
871
846
|
}
|
|
872
|
-
|
|
847
|
+
if (!NavigatorImpl) {
|
|
848
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
849
|
+
}
|
|
850
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
873
851
|
}
|
|
874
852
|
/**
|
|
875
|
-
* Get
|
|
853
|
+
* Get cards with suitability scores and provenance trails.
|
|
854
|
+
*
|
|
855
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
856
|
+
*
|
|
857
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
858
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
859
|
+
* documenting how strategies contributed to the final score.
|
|
860
|
+
*
|
|
861
|
+
* ## Implementation Required
|
|
862
|
+
* All navigation strategies MUST override this method. The base class does
|
|
863
|
+
* not provide a default implementation.
|
|
864
|
+
*
|
|
865
|
+
* ## For Generators
|
|
866
|
+
* Override this method to generate candidates and compute scores based on
|
|
867
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
868
|
+
* initial provenance entry with action='generated'.
|
|
869
|
+
*
|
|
870
|
+
* ## For Filters
|
|
871
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
872
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
873
|
+
*
|
|
874
|
+
* @param limit - Maximum cards to return
|
|
875
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
876
876
|
*/
|
|
877
|
-
async
|
|
878
|
-
|
|
879
|
-
(g) => g instanceof ContentNavigator
|
|
880
|
-
);
|
|
881
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
882
|
-
const seen = /* @__PURE__ */ new Set();
|
|
883
|
-
const merged = [];
|
|
884
|
-
for (const reviews of results) {
|
|
885
|
-
for (const review of reviews) {
|
|
886
|
-
if (!seen.has(review.cardID)) {
|
|
887
|
-
seen.add(review.cardID);
|
|
888
|
-
merged.push(review);
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
return merged;
|
|
877
|
+
async getWeightedCards(_limit) {
|
|
878
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
893
879
|
}
|
|
894
880
|
};
|
|
895
881
|
}
|
|
896
882
|
});
|
|
897
883
|
|
|
898
884
|
// src/core/navigators/Pipeline.ts
|
|
899
|
-
var Pipeline_exports = {};
|
|
900
|
-
__export(Pipeline_exports, {
|
|
901
|
-
Pipeline: () => Pipeline
|
|
902
|
-
});
|
|
903
885
|
function logPipelineConfig(generator, filters) {
|
|
904
886
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
905
887
|
logger.info(
|
|
@@ -959,6 +941,11 @@ var init_Pipeline = __esm({
|
|
|
959
941
|
this.filters = filters;
|
|
960
942
|
this.user = user;
|
|
961
943
|
this.course = course;
|
|
944
|
+
course.getCourseConfig().then((cfg) => {
|
|
945
|
+
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
946
|
+
}).catch((e) => {
|
|
947
|
+
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
948
|
+
});
|
|
962
949
|
logPipelineConfig(generator, filters);
|
|
963
950
|
}
|
|
964
951
|
/**
|
|
@@ -995,7 +982,13 @@ var init_Pipeline = __esm({
|
|
|
995
982
|
cards.sort((a, b) => b.score - a.score);
|
|
996
983
|
const result = cards.slice(0, limit);
|
|
997
984
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
998
|
-
logExecutionSummary(
|
|
985
|
+
logExecutionSummary(
|
|
986
|
+
this.generator.name,
|
|
987
|
+
generatedCount,
|
|
988
|
+
this.filters.length,
|
|
989
|
+
result.length,
|
|
990
|
+
topScores
|
|
991
|
+
);
|
|
999
992
|
logCardProvenance(result, 3);
|
|
1000
993
|
return result;
|
|
1001
994
|
}
|
|
@@ -1044,48 +1037,155 @@ var init_Pipeline = __esm({
|
|
|
1044
1037
|
userElo
|
|
1045
1038
|
};
|
|
1046
1039
|
}
|
|
1047
|
-
// ===========================================================================
|
|
1048
|
-
// Legacy StudyContentSource methods
|
|
1049
|
-
// ===========================================================================
|
|
1050
|
-
//
|
|
1051
|
-
// These delegate to the generator for backward compatibility.
|
|
1052
|
-
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
1053
|
-
//
|
|
1054
1040
|
/**
|
|
1055
|
-
* Get
|
|
1056
|
-
* Delegates to the generator if it supports the legacy interface.
|
|
1041
|
+
* Get the course ID for this pipeline.
|
|
1057
1042
|
*/
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1043
|
+
getCourseID() {
|
|
1044
|
+
return this.course.getCourseID();
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1051
|
+
var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
1052
|
+
var init_CompositeGenerator = __esm({
|
|
1053
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
1054
|
+
"use strict";
|
|
1055
|
+
init_navigators();
|
|
1056
|
+
init_logger();
|
|
1057
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
1058
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
1059
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
1060
|
+
/** Human-readable name for CardGenerator interface */
|
|
1061
|
+
name = "Composite Generator";
|
|
1062
|
+
generators;
|
|
1063
|
+
aggregationMode;
|
|
1064
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1065
|
+
super();
|
|
1066
|
+
this.generators = generators;
|
|
1067
|
+
this.aggregationMode = aggregationMode;
|
|
1068
|
+
if (generators.length === 0) {
|
|
1069
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
1061
1070
|
}
|
|
1062
|
-
|
|
1071
|
+
logger.debug(
|
|
1072
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
1073
|
+
);
|
|
1063
1074
|
}
|
|
1064
1075
|
/**
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1076
|
+
* Creates a CompositeGenerator from strategy data.
|
|
1077
|
+
*
|
|
1078
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
1067
1079
|
*/
|
|
1068
|
-
async
|
|
1069
|
-
|
|
1070
|
-
|
|
1080
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1081
|
+
const generators = await Promise.all(
|
|
1082
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
1083
|
+
);
|
|
1084
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
1088
|
+
*
|
|
1089
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
1090
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
1091
|
+
*
|
|
1092
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1093
|
+
* CardGenerator interface signature (limit, context).
|
|
1094
|
+
*
|
|
1095
|
+
* @param limit - Maximum number of cards to return
|
|
1096
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
1097
|
+
*/
|
|
1098
|
+
async getWeightedCards(limit, context) {
|
|
1099
|
+
if (!context) {
|
|
1100
|
+
throw new Error(
|
|
1101
|
+
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
const results = await Promise.all(
|
|
1105
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
1106
|
+
);
|
|
1107
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
1108
|
+
for (const cards of results) {
|
|
1109
|
+
for (const card of cards) {
|
|
1110
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
1111
|
+
existing.push(card);
|
|
1112
|
+
byCardId.set(card.cardId, existing);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const merged = [];
|
|
1116
|
+
for (const [, cards] of byCardId) {
|
|
1117
|
+
const aggregatedScore = this.aggregateScores(cards);
|
|
1118
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
1119
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1120
|
+
const initialScore = cards[0].score;
|
|
1121
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1122
|
+
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1123
|
+
merged.push({
|
|
1124
|
+
...cards[0],
|
|
1125
|
+
score: finalScore,
|
|
1126
|
+
provenance: [
|
|
1127
|
+
...mergedProvenance,
|
|
1128
|
+
{
|
|
1129
|
+
strategy: "composite",
|
|
1130
|
+
strategyName: "Composite Generator",
|
|
1131
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
1132
|
+
action,
|
|
1133
|
+
score: finalScore,
|
|
1134
|
+
reason
|
|
1135
|
+
}
|
|
1136
|
+
]
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Build human-readable reason for score aggregation.
|
|
1143
|
+
*/
|
|
1144
|
+
buildAggregationReason(cards, finalScore) {
|
|
1145
|
+
const count = cards.length;
|
|
1146
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1147
|
+
if (count === 1) {
|
|
1148
|
+
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1149
|
+
}
|
|
1150
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1151
|
+
switch (this.aggregationMode) {
|
|
1152
|
+
case "max" /* MAX */:
|
|
1153
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1154
|
+
case "average" /* AVERAGE */:
|
|
1155
|
+
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1156
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1157
|
+
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1158
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1159
|
+
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1160
|
+
}
|
|
1161
|
+
default:
|
|
1162
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1071
1163
|
}
|
|
1072
|
-
return [];
|
|
1073
1164
|
}
|
|
1074
1165
|
/**
|
|
1075
|
-
*
|
|
1166
|
+
* Aggregate scores from multiple generators for the same card.
|
|
1076
1167
|
*/
|
|
1077
|
-
|
|
1078
|
-
|
|
1168
|
+
aggregateScores(cards) {
|
|
1169
|
+
const scores = cards.map((c) => c.score);
|
|
1170
|
+
switch (this.aggregationMode) {
|
|
1171
|
+
case "max" /* MAX */:
|
|
1172
|
+
return Math.max(...scores);
|
|
1173
|
+
case "average" /* AVERAGE */:
|
|
1174
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1175
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1176
|
+
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1177
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1178
|
+
return avg * frequencyBoost;
|
|
1179
|
+
}
|
|
1180
|
+
default:
|
|
1181
|
+
return scores[0];
|
|
1182
|
+
}
|
|
1079
1183
|
}
|
|
1080
1184
|
};
|
|
1081
1185
|
}
|
|
1082
1186
|
});
|
|
1083
1187
|
|
|
1084
1188
|
// src/core/navigators/PipelineAssembler.ts
|
|
1085
|
-
var PipelineAssembler_exports = {};
|
|
1086
|
-
__export(PipelineAssembler_exports, {
|
|
1087
|
-
PipelineAssembler: () => PipelineAssembler
|
|
1088
|
-
});
|
|
1089
1189
|
var PipelineAssembler;
|
|
1090
1190
|
var init_PipelineAssembler = __esm({
|
|
1091
1191
|
"src/core/navigators/PipelineAssembler.ts"() {
|
|
@@ -1206,14 +1306,10 @@ var init_PipelineAssembler = __esm({
|
|
|
1206
1306
|
}
|
|
1207
1307
|
});
|
|
1208
1308
|
|
|
1209
|
-
// src/core/navigators/elo.ts
|
|
1210
|
-
var elo_exports = {};
|
|
1211
|
-
__export(elo_exports, {
|
|
1212
|
-
default: () => ELONavigator
|
|
1213
|
-
});
|
|
1309
|
+
// src/core/navigators/generators/elo.ts
|
|
1214
1310
|
var import_common6, ELONavigator;
|
|
1215
1311
|
var init_elo = __esm({
|
|
1216
|
-
"src/core/navigators/elo.ts"() {
|
|
1312
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
1217
1313
|
"use strict";
|
|
1218
1314
|
init_navigators();
|
|
1219
1315
|
import_common6 = require("@vue-skuilder/common");
|
|
@@ -1224,50 +1320,6 @@ var init_elo = __esm({
|
|
|
1224
1320
|
super(user, course, strategyData);
|
|
1225
1321
|
this.name = strategyData?.name || "ELO";
|
|
1226
1322
|
}
|
|
1227
|
-
async getPendingReviews() {
|
|
1228
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1229
|
-
const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
|
|
1230
|
-
const ratedReviews = reviews.map((r, i) => {
|
|
1231
|
-
const ratedR = {
|
|
1232
|
-
...r,
|
|
1233
|
-
...elo[i]
|
|
1234
|
-
};
|
|
1235
|
-
return ratedR;
|
|
1236
|
-
});
|
|
1237
|
-
ratedReviews.sort((a, b) => {
|
|
1238
|
-
return a.global.score - b.global.score;
|
|
1239
|
-
});
|
|
1240
|
-
return ratedReviews.map((r) => {
|
|
1241
|
-
return {
|
|
1242
|
-
...r,
|
|
1243
|
-
contentSourceType: "course",
|
|
1244
|
-
contentSourceID: this.course.getCourseID(),
|
|
1245
|
-
cardID: r.cardId,
|
|
1246
|
-
courseID: r.courseId,
|
|
1247
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
1248
|
-
reviewID: r._id,
|
|
1249
|
-
status: "review"
|
|
1250
|
-
};
|
|
1251
|
-
});
|
|
1252
|
-
}
|
|
1253
|
-
async getNewCards(limit = 99) {
|
|
1254
|
-
const activeCards = await this.user.getActiveCards();
|
|
1255
|
-
return (await this.course.getCardsCenteredAtELO(
|
|
1256
|
-
{ limit, elo: "user" },
|
|
1257
|
-
(c) => {
|
|
1258
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
1259
|
-
return false;
|
|
1260
|
-
} else {
|
|
1261
|
-
return true;
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
)).map((c) => {
|
|
1265
|
-
return {
|
|
1266
|
-
...c,
|
|
1267
|
-
status: "new"
|
|
1268
|
-
};
|
|
1269
|
-
});
|
|
1270
|
-
}
|
|
1271
1323
|
/**
|
|
1272
1324
|
* Get new cards with suitability scores based on ELO distance.
|
|
1273
1325
|
*
|
|
@@ -1292,7 +1344,11 @@ var init_elo = __esm({
|
|
|
1292
1344
|
const userElo = (0, import_common6.toCourseElo)(courseReg.elo);
|
|
1293
1345
|
userGlobalElo = userElo.global.score;
|
|
1294
1346
|
}
|
|
1295
|
-
const
|
|
1347
|
+
const activeCards = await this.user.getActiveCards();
|
|
1348
|
+
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
1349
|
+
{ limit, elo: "user" },
|
|
1350
|
+
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
1351
|
+
)).map((c) => ({ ...c, status: "new" }));
|
|
1296
1352
|
const cardIds = newCards.map((c) => c.cardID);
|
|
1297
1353
|
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1298
1354
|
const scored = newCards.map((c, i) => {
|
|
@@ -1322,950 +1378,39 @@ var init_elo = __esm({
|
|
|
1322
1378
|
}
|
|
1323
1379
|
});
|
|
1324
1380
|
|
|
1325
|
-
// src/core/navigators/
|
|
1326
|
-
var
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
|
|
1330
|
-
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1331
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1332
|
-
});
|
|
1333
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1334
|
-
const normalizedDistance = distance / halfLife;
|
|
1335
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1336
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1337
|
-
}
|
|
1338
|
-
function createEloDistanceFilter(config) {
|
|
1339
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1340
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1341
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1342
|
-
return {
|
|
1343
|
-
name: "ELO Distance Filter",
|
|
1344
|
-
async transform(cards, context) {
|
|
1345
|
-
const { course, userElo } = context;
|
|
1346
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1347
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1348
|
-
return cards.map((card, i) => {
|
|
1349
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1350
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1351
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1352
|
-
const newScore = card.score * multiplier;
|
|
1353
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1354
|
-
return {
|
|
1355
|
-
...card,
|
|
1356
|
-
score: newScore,
|
|
1357
|
-
provenance: [
|
|
1358
|
-
...card.provenance,
|
|
1359
|
-
{
|
|
1360
|
-
strategy: "eloDistance",
|
|
1361
|
-
strategyName: "ELO Distance Filter",
|
|
1362
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1363
|
-
action,
|
|
1364
|
-
score: newScore,
|
|
1365
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1366
|
-
}
|
|
1367
|
-
]
|
|
1368
|
-
};
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
};
|
|
1372
|
-
}
|
|
1373
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1374
|
-
var init_eloDistance = __esm({
|
|
1375
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1376
|
-
"use strict";
|
|
1377
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1378
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1379
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1380
|
-
}
|
|
1381
|
-
});
|
|
1382
|
-
|
|
1383
|
-
// src/core/navigators/filters/userTagPreference.ts
|
|
1384
|
-
var userTagPreference_exports = {};
|
|
1385
|
-
__export(userTagPreference_exports, {
|
|
1386
|
-
default: () => UserTagPreferenceFilter
|
|
1387
|
-
});
|
|
1388
|
-
var UserTagPreferenceFilter;
|
|
1389
|
-
var init_userTagPreference = __esm({
|
|
1390
|
-
"src/core/navigators/filters/userTagPreference.ts"() {
|
|
1381
|
+
// src/core/navigators/generators/srs.ts
|
|
1382
|
+
var import_moment3, SRSNavigator;
|
|
1383
|
+
var init_srs = __esm({
|
|
1384
|
+
"src/core/navigators/generators/srs.ts"() {
|
|
1391
1385
|
"use strict";
|
|
1386
|
+
import_moment3 = __toESM(require("moment"), 1);
|
|
1392
1387
|
init_navigators();
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
/** Human-readable name for
|
|
1388
|
+
init_logger();
|
|
1389
|
+
SRSNavigator = class extends ContentNavigator {
|
|
1390
|
+
/** Human-readable name for CardGenerator interface */
|
|
1396
1391
|
name;
|
|
1397
1392
|
constructor(user, course, strategyData) {
|
|
1398
1393
|
super(user, course, strategyData);
|
|
1399
|
-
this.
|
|
1400
|
-
this.name = strategyData.name || "User Tag Preferences";
|
|
1401
|
-
}
|
|
1402
|
-
/**
|
|
1403
|
-
* Compute multiplier for a card based on its tags and user preferences.
|
|
1404
|
-
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
1405
|
-
*/
|
|
1406
|
-
computeMultiplier(cardTags, boostMap) {
|
|
1407
|
-
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
|
|
1408
|
-
if (multipliers.length === 0) {
|
|
1409
|
-
return 1;
|
|
1410
|
-
}
|
|
1411
|
-
return Math.max(...multipliers);
|
|
1412
|
-
}
|
|
1413
|
-
/**
|
|
1414
|
-
* Build human-readable reason for the filter's decision.
|
|
1415
|
-
*/
|
|
1416
|
-
buildReason(cardTags, boostMap, multiplier) {
|
|
1417
|
-
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
1418
|
-
if (multiplier === 0) {
|
|
1419
|
-
return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
|
|
1420
|
-
}
|
|
1421
|
-
if (multiplier < 1) {
|
|
1422
|
-
return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1423
|
-
}
|
|
1424
|
-
if (multiplier > 1) {
|
|
1425
|
-
return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1426
|
-
}
|
|
1427
|
-
return "No matching user preferences";
|
|
1394
|
+
this.name = strategyData?.name || "SRS";
|
|
1428
1395
|
}
|
|
1429
1396
|
/**
|
|
1430
|
-
*
|
|
1397
|
+
* Get review cards scored by urgency.
|
|
1398
|
+
*
|
|
1399
|
+
* Score formula combines:
|
|
1400
|
+
* - Relative overdueness: hoursOverdue / intervalHours
|
|
1401
|
+
* - Interval recency: exponential decay favoring shorter intervals
|
|
1402
|
+
*
|
|
1403
|
+
* Cards not yet due are excluded (not scored as 0).
|
|
1404
|
+
*
|
|
1405
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1406
|
+
* CardGenerator interface signature (limit, context).
|
|
1431
1407
|
*
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
* 2. If no preferences, pass through unchanged
|
|
1435
|
-
* 3. For each card:
|
|
1436
|
-
* - Look up tag in boost record
|
|
1437
|
-
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
1438
|
-
* - If multiple tags match: use max multiplier
|
|
1439
|
-
* - Append provenance with clear reason
|
|
1408
|
+
* @param limit - Maximum number of cards to return
|
|
1409
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1440
1410
|
*/
|
|
1441
|
-
async
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
return cards.map((card) => ({
|
|
1445
|
-
...card,
|
|
1446
|
-
provenance: [
|
|
1447
|
-
...card.provenance,
|
|
1448
|
-
{
|
|
1449
|
-
strategy: "userTagPreference",
|
|
1450
|
-
strategyName: this.strategyName || this.name,
|
|
1451
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1452
|
-
action: "passed",
|
|
1453
|
-
score: card.score,
|
|
1454
|
-
reason: "No user tag preferences configured"
|
|
1455
|
-
}
|
|
1456
|
-
]
|
|
1457
|
-
}));
|
|
1458
|
-
}
|
|
1459
|
-
const adjusted = await Promise.all(
|
|
1460
|
-
cards.map(async (card) => {
|
|
1461
|
-
const cardTags = card.tags ?? [];
|
|
1462
|
-
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
1463
|
-
const finalScore = Math.min(1, card.score * multiplier);
|
|
1464
|
-
let action;
|
|
1465
|
-
if (multiplier === 0 || multiplier < 1) {
|
|
1466
|
-
action = "penalized";
|
|
1467
|
-
} else if (multiplier > 1) {
|
|
1468
|
-
action = "boosted";
|
|
1469
|
-
} else {
|
|
1470
|
-
action = "passed";
|
|
1471
|
-
}
|
|
1472
|
-
return {
|
|
1473
|
-
...card,
|
|
1474
|
-
score: finalScore,
|
|
1475
|
-
provenance: [
|
|
1476
|
-
...card.provenance,
|
|
1477
|
-
{
|
|
1478
|
-
strategy: "userTagPreference",
|
|
1479
|
-
strategyName: this.strategyName || this.name,
|
|
1480
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1481
|
-
action,
|
|
1482
|
-
score: finalScore,
|
|
1483
|
-
reason: this.buildReason(cardTags, prefs.boost, multiplier)
|
|
1484
|
-
}
|
|
1485
|
-
]
|
|
1486
|
-
};
|
|
1487
|
-
})
|
|
1488
|
-
);
|
|
1489
|
-
return adjusted;
|
|
1490
|
-
}
|
|
1491
|
-
/**
|
|
1492
|
-
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
1493
|
-
*/
|
|
1494
|
-
async getWeightedCards(_limit) {
|
|
1495
|
-
throw new Error(
|
|
1496
|
-
"UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1497
|
-
);
|
|
1498
|
-
}
|
|
1499
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1500
|
-
async getNewCards(_n) {
|
|
1501
|
-
return [];
|
|
1502
|
-
}
|
|
1503
|
-
async getPendingReviews() {
|
|
1504
|
-
return [];
|
|
1505
|
-
}
|
|
1506
|
-
};
|
|
1507
|
-
}
|
|
1508
|
-
});
|
|
1509
|
-
|
|
1510
|
-
// src/core/navigators/filters/index.ts
|
|
1511
|
-
var filters_exports = {};
|
|
1512
|
-
__export(filters_exports, {
|
|
1513
|
-
UserTagPreferenceFilter: () => UserTagPreferenceFilter,
|
|
1514
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1515
|
-
});
|
|
1516
|
-
var init_filters = __esm({
|
|
1517
|
-
"src/core/navigators/filters/index.ts"() {
|
|
1518
|
-
"use strict";
|
|
1519
|
-
init_eloDistance();
|
|
1520
|
-
init_userTagPreference();
|
|
1521
|
-
}
|
|
1522
|
-
});
|
|
1523
|
-
|
|
1524
|
-
// src/core/navigators/filters/types.ts
|
|
1525
|
-
var types_exports = {};
|
|
1526
|
-
var init_types = __esm({
|
|
1527
|
-
"src/core/navigators/filters/types.ts"() {
|
|
1528
|
-
"use strict";
|
|
1529
|
-
}
|
|
1530
|
-
});
|
|
1531
|
-
|
|
1532
|
-
// src/core/navigators/generators/index.ts
|
|
1533
|
-
var generators_exports = {};
|
|
1534
|
-
var init_generators = __esm({
|
|
1535
|
-
"src/core/navigators/generators/index.ts"() {
|
|
1536
|
-
"use strict";
|
|
1537
|
-
}
|
|
1538
|
-
});
|
|
1539
|
-
|
|
1540
|
-
// src/core/navigators/generators/types.ts
|
|
1541
|
-
var types_exports2 = {};
|
|
1542
|
-
var init_types2 = __esm({
|
|
1543
|
-
"src/core/navigators/generators/types.ts"() {
|
|
1544
|
-
"use strict";
|
|
1545
|
-
}
|
|
1546
|
-
});
|
|
1547
|
-
|
|
1548
|
-
// src/core/navigators/hardcodedOrder.ts
|
|
1549
|
-
var hardcodedOrder_exports = {};
|
|
1550
|
-
__export(hardcodedOrder_exports, {
|
|
1551
|
-
default: () => HardcodedOrderNavigator
|
|
1552
|
-
});
|
|
1553
|
-
var HardcodedOrderNavigator;
|
|
1554
|
-
var init_hardcodedOrder = __esm({
|
|
1555
|
-
"src/core/navigators/hardcodedOrder.ts"() {
|
|
1556
|
-
"use strict";
|
|
1557
|
-
init_navigators();
|
|
1558
|
-
init_logger();
|
|
1559
|
-
HardcodedOrderNavigator = class extends ContentNavigator {
|
|
1560
|
-
/** Human-readable name for CardGenerator interface */
|
|
1561
|
-
name;
|
|
1562
|
-
orderedCardIds = [];
|
|
1563
|
-
constructor(user, course, strategyData) {
|
|
1564
|
-
super(user, course, strategyData);
|
|
1565
|
-
this.name = strategyData.name || "Hardcoded Order";
|
|
1566
|
-
if (strategyData.serializedData) {
|
|
1567
|
-
try {
|
|
1568
|
-
this.orderedCardIds = JSON.parse(strategyData.serializedData);
|
|
1569
|
-
} catch (e) {
|
|
1570
|
-
logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
async getPendingReviews() {
|
|
1575
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1576
|
-
return reviews.map((r) => {
|
|
1577
|
-
return {
|
|
1578
|
-
...r,
|
|
1579
|
-
contentSourceType: "course",
|
|
1580
|
-
contentSourceID: this.course.getCourseID(),
|
|
1581
|
-
cardID: r.cardId,
|
|
1582
|
-
courseID: r.courseId,
|
|
1583
|
-
reviewID: r._id,
|
|
1584
|
-
status: "review"
|
|
1585
|
-
};
|
|
1586
|
-
});
|
|
1587
|
-
}
|
|
1588
|
-
async getNewCards(limit = 99) {
|
|
1589
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1590
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1591
|
-
const cardsToReturn = newCardIds.slice(0, limit);
|
|
1592
|
-
return cardsToReturn.map((cardId) => {
|
|
1593
|
-
return {
|
|
1594
|
-
cardID: cardId,
|
|
1595
|
-
courseID: this.course.getCourseID(),
|
|
1596
|
-
contentSourceType: "course",
|
|
1597
|
-
contentSourceID: this.course.getCourseID(),
|
|
1598
|
-
status: "new"
|
|
1599
|
-
};
|
|
1600
|
-
});
|
|
1601
|
-
}
|
|
1602
|
-
/**
|
|
1603
|
-
* Get cards in hardcoded order with scores based on position.
|
|
1604
|
-
*
|
|
1605
|
-
* Earlier cards in the sequence get higher scores.
|
|
1606
|
-
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
1607
|
-
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
1608
|
-
*
|
|
1609
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1610
|
-
* CardGenerator interface signature (limit, context).
|
|
1611
|
-
*
|
|
1612
|
-
* @param limit - Maximum number of cards to return
|
|
1613
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1614
|
-
*/
|
|
1615
|
-
async getWeightedCards(limit, _context) {
|
|
1616
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1617
|
-
const reviews = await this.getPendingReviews();
|
|
1618
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1619
|
-
const totalCards = newCardIds.length;
|
|
1620
|
-
const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
1621
|
-
const position = index + 1;
|
|
1622
|
-
const score = Math.max(0.5, 1 - index / totalCards * 0.5);
|
|
1623
|
-
return {
|
|
1624
|
-
cardId,
|
|
1625
|
-
courseId: this.course.getCourseID(),
|
|
1626
|
-
score,
|
|
1627
|
-
provenance: [
|
|
1628
|
-
{
|
|
1629
|
-
strategy: "hardcodedOrder",
|
|
1630
|
-
strategyName: this.strategyName || this.name,
|
|
1631
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1632
|
-
action: "generated",
|
|
1633
|
-
score,
|
|
1634
|
-
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
|
|
1635
|
-
}
|
|
1636
|
-
]
|
|
1637
|
-
};
|
|
1638
|
-
});
|
|
1639
|
-
const scoredReviews = reviews.map((r) => ({
|
|
1640
|
-
cardId: r.cardID,
|
|
1641
|
-
courseId: r.courseID,
|
|
1642
|
-
score: 1,
|
|
1643
|
-
provenance: [
|
|
1644
|
-
{
|
|
1645
|
-
strategy: "hardcodedOrder",
|
|
1646
|
-
strategyName: this.strategyName || this.name,
|
|
1647
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1648
|
-
action: "generated",
|
|
1649
|
-
score: 1,
|
|
1650
|
-
reason: "Scheduled review, highest priority"
|
|
1651
|
-
}
|
|
1652
|
-
]
|
|
1653
|
-
}));
|
|
1654
|
-
const all = [...scoredReviews, ...scoredNew];
|
|
1655
|
-
all.sort((a, b) => b.score - a.score);
|
|
1656
|
-
return all.slice(0, limit);
|
|
1657
|
-
}
|
|
1658
|
-
};
|
|
1659
|
-
}
|
|
1660
|
-
});
|
|
1661
|
-
|
|
1662
|
-
// src/core/navigators/hierarchyDefinition.ts
|
|
1663
|
-
var hierarchyDefinition_exports = {};
|
|
1664
|
-
__export(hierarchyDefinition_exports, {
|
|
1665
|
-
default: () => HierarchyDefinitionNavigator
|
|
1666
|
-
});
|
|
1667
|
-
var import_common7, DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1668
|
-
var init_hierarchyDefinition = __esm({
|
|
1669
|
-
"src/core/navigators/hierarchyDefinition.ts"() {
|
|
1670
|
-
"use strict";
|
|
1671
|
-
init_navigators();
|
|
1672
|
-
import_common7 = require("@vue-skuilder/common");
|
|
1673
|
-
DEFAULT_MIN_COUNT = 3;
|
|
1674
|
-
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1675
|
-
config;
|
|
1676
|
-
_strategyData;
|
|
1677
|
-
/** Human-readable name for CardFilter interface */
|
|
1678
|
-
name;
|
|
1679
|
-
constructor(user, course, _strategyData) {
|
|
1680
|
-
super(user, course, _strategyData);
|
|
1681
|
-
this._strategyData = _strategyData;
|
|
1682
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1683
|
-
this.name = _strategyData.name || "Hierarchy Definition";
|
|
1684
|
-
}
|
|
1685
|
-
parseConfig(serializedData) {
|
|
1686
|
-
try {
|
|
1687
|
-
const parsed = JSON.parse(serializedData);
|
|
1688
|
-
return {
|
|
1689
|
-
prerequisites: parsed.prerequisites || {}
|
|
1690
|
-
};
|
|
1691
|
-
} catch {
|
|
1692
|
-
return {
|
|
1693
|
-
prerequisites: {}
|
|
1694
|
-
};
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
/**
|
|
1698
|
-
* Check if a specific prerequisite is satisfied
|
|
1699
|
-
*/
|
|
1700
|
-
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1701
|
-
if (!userTagElo) return false;
|
|
1702
|
-
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1703
|
-
if (userTagElo.count < minCount) return false;
|
|
1704
|
-
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1705
|
-
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1706
|
-
} else {
|
|
1707
|
-
return userTagElo.score >= userGlobalElo;
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
/**
|
|
1711
|
-
* Get the set of tags the user has mastered.
|
|
1712
|
-
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1713
|
-
*/
|
|
1714
|
-
async getMasteredTags(context) {
|
|
1715
|
-
const mastered = /* @__PURE__ */ new Set();
|
|
1716
|
-
try {
|
|
1717
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1718
|
-
const userElo = (0, import_common7.toCourseElo)(courseReg.elo);
|
|
1719
|
-
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1720
|
-
for (const prereq of prereqs) {
|
|
1721
|
-
const tagElo = userElo.tags[prereq.tag];
|
|
1722
|
-
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1723
|
-
mastered.add(prereq.tag);
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
}
|
|
1727
|
-
} catch {
|
|
1728
|
-
}
|
|
1729
|
-
return mastered;
|
|
1730
|
-
}
|
|
1731
|
-
/**
|
|
1732
|
-
* Get the set of tags that are unlocked (prerequisites met)
|
|
1733
|
-
*/
|
|
1734
|
-
getUnlockedTags(masteredTags) {
|
|
1735
|
-
const unlocked = /* @__PURE__ */ new Set();
|
|
1736
|
-
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1737
|
-
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1738
|
-
if (allPrereqsMet) {
|
|
1739
|
-
unlocked.add(tagId);
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
return unlocked;
|
|
1743
|
-
}
|
|
1744
|
-
/**
|
|
1745
|
-
* Check if a tag has prerequisites defined in config
|
|
1746
|
-
*/
|
|
1747
|
-
hasPrerequisites(tagId) {
|
|
1748
|
-
return tagId in this.config.prerequisites;
|
|
1749
|
-
}
|
|
1750
|
-
/**
|
|
1751
|
-
* Check if a card is unlocked and generate reason.
|
|
1752
|
-
*/
|
|
1753
|
-
async checkCardUnlock(card, course, unlockedTags, masteredTags) {
|
|
1754
|
-
try {
|
|
1755
|
-
const cardTags = card.tags ?? [];
|
|
1756
|
-
const lockedTags = cardTags.filter(
|
|
1757
|
-
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1758
|
-
);
|
|
1759
|
-
if (lockedTags.length === 0) {
|
|
1760
|
-
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1761
|
-
return {
|
|
1762
|
-
isUnlocked: true,
|
|
1763
|
-
reason: `Prerequisites met, tags: ${tagList}`
|
|
1764
|
-
};
|
|
1765
|
-
}
|
|
1766
|
-
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1767
|
-
const prereqs = this.config.prerequisites[tag] || [];
|
|
1768
|
-
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1769
|
-
});
|
|
1770
|
-
return {
|
|
1771
|
-
isUnlocked: false,
|
|
1772
|
-
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1773
|
-
};
|
|
1774
|
-
} catch {
|
|
1775
|
-
return {
|
|
1776
|
-
isUnlocked: true,
|
|
1777
|
-
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1778
|
-
};
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
/**
|
|
1782
|
-
* CardFilter.transform implementation.
|
|
1783
|
-
*
|
|
1784
|
-
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1785
|
-
*/
|
|
1786
|
-
async transform(cards, context) {
|
|
1787
|
-
const masteredTags = await this.getMasteredTags(context);
|
|
1788
|
-
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1789
|
-
const gated = [];
|
|
1790
|
-
for (const card of cards) {
|
|
1791
|
-
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1792
|
-
card,
|
|
1793
|
-
context.course,
|
|
1794
|
-
unlockedTags,
|
|
1795
|
-
masteredTags
|
|
1796
|
-
);
|
|
1797
|
-
const finalScore = isUnlocked ? card.score : 0;
|
|
1798
|
-
const action = isUnlocked ? "passed" : "penalized";
|
|
1799
|
-
gated.push({
|
|
1800
|
-
...card,
|
|
1801
|
-
score: finalScore,
|
|
1802
|
-
provenance: [
|
|
1803
|
-
...card.provenance,
|
|
1804
|
-
{
|
|
1805
|
-
strategy: "hierarchyDefinition",
|
|
1806
|
-
strategyName: this.strategyName || this.name,
|
|
1807
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1808
|
-
action,
|
|
1809
|
-
score: finalScore,
|
|
1810
|
-
reason
|
|
1811
|
-
}
|
|
1812
|
-
]
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
return gated;
|
|
1816
|
-
}
|
|
1817
|
-
/**
|
|
1818
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1819
|
-
*
|
|
1820
|
-
* Use transform() via Pipeline instead.
|
|
1821
|
-
*/
|
|
1822
|
-
async getWeightedCards(_limit) {
|
|
1823
|
-
throw new Error(
|
|
1824
|
-
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1825
|
-
);
|
|
1826
|
-
}
|
|
1827
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1828
|
-
async getNewCards(_n) {
|
|
1829
|
-
return [];
|
|
1830
|
-
}
|
|
1831
|
-
async getPendingReviews() {
|
|
1832
|
-
return [];
|
|
1833
|
-
}
|
|
1834
|
-
};
|
|
1835
|
-
}
|
|
1836
|
-
});
|
|
1837
|
-
|
|
1838
|
-
// src/core/navigators/inferredPreference.ts
|
|
1839
|
-
var inferredPreference_exports = {};
|
|
1840
|
-
__export(inferredPreference_exports, {
|
|
1841
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
|
|
1842
|
-
});
|
|
1843
|
-
var INFERRED_PREFERENCE_NAVIGATOR_STUB;
|
|
1844
|
-
var init_inferredPreference = __esm({
|
|
1845
|
-
"src/core/navigators/inferredPreference.ts"() {
|
|
1846
|
-
"use strict";
|
|
1847
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
|
|
1848
|
-
}
|
|
1849
|
-
});
|
|
1850
|
-
|
|
1851
|
-
// src/core/navigators/interferenceMitigator.ts
|
|
1852
|
-
var interferenceMitigator_exports = {};
|
|
1853
|
-
__export(interferenceMitigator_exports, {
|
|
1854
|
-
default: () => InterferenceMitigatorNavigator
|
|
1855
|
-
});
|
|
1856
|
-
var import_common8, DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
1857
|
-
var init_interferenceMitigator = __esm({
|
|
1858
|
-
"src/core/navigators/interferenceMitigator.ts"() {
|
|
1859
|
-
"use strict";
|
|
1860
|
-
init_navigators();
|
|
1861
|
-
import_common8 = require("@vue-skuilder/common");
|
|
1862
|
-
DEFAULT_MIN_COUNT2 = 10;
|
|
1863
|
-
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
1864
|
-
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
1865
|
-
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
1866
|
-
config;
|
|
1867
|
-
_strategyData;
|
|
1868
|
-
/** Human-readable name for CardFilter interface */
|
|
1869
|
-
name;
|
|
1870
|
-
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
1871
|
-
interferenceMap;
|
|
1872
|
-
constructor(user, course, _strategyData) {
|
|
1873
|
-
super(user, course, _strategyData);
|
|
1874
|
-
this._strategyData = _strategyData;
|
|
1875
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1876
|
-
this.interferenceMap = this.buildInterferenceMap();
|
|
1877
|
-
this.name = _strategyData.name || "Interference Mitigator";
|
|
1878
|
-
}
|
|
1879
|
-
parseConfig(serializedData) {
|
|
1880
|
-
try {
|
|
1881
|
-
const parsed = JSON.parse(serializedData);
|
|
1882
|
-
let sets = parsed.interferenceSets || [];
|
|
1883
|
-
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
1884
|
-
sets = sets.map((tags) => ({ tags }));
|
|
1885
|
-
}
|
|
1886
|
-
return {
|
|
1887
|
-
interferenceSets: sets,
|
|
1888
|
-
maturityThreshold: {
|
|
1889
|
-
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
1890
|
-
minElo: parsed.maturityThreshold?.minElo,
|
|
1891
|
-
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
1892
|
-
},
|
|
1893
|
-
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
1894
|
-
};
|
|
1895
|
-
} catch {
|
|
1896
|
-
return {
|
|
1897
|
-
interferenceSets: [],
|
|
1898
|
-
maturityThreshold: {
|
|
1899
|
-
minCount: DEFAULT_MIN_COUNT2,
|
|
1900
|
-
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
1901
|
-
},
|
|
1902
|
-
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
1903
|
-
};
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
/**
|
|
1907
|
-
* Build a map from each tag to its interference partners with decay coefficients.
|
|
1908
|
-
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
1909
|
-
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
1910
|
-
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
1911
|
-
* - etc.
|
|
1912
|
-
*/
|
|
1913
|
-
buildInterferenceMap() {
|
|
1914
|
-
const map = /* @__PURE__ */ new Map();
|
|
1915
|
-
for (const group of this.config.interferenceSets) {
|
|
1916
|
-
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
1917
|
-
for (const tag of group.tags) {
|
|
1918
|
-
if (!map.has(tag)) {
|
|
1919
|
-
map.set(tag, []);
|
|
1920
|
-
}
|
|
1921
|
-
const partners = map.get(tag);
|
|
1922
|
-
for (const other of group.tags) {
|
|
1923
|
-
if (other !== tag) {
|
|
1924
|
-
const existing = partners.find((p) => p.partner === other);
|
|
1925
|
-
if (existing) {
|
|
1926
|
-
existing.decay = Math.max(existing.decay, decay);
|
|
1927
|
-
} else {
|
|
1928
|
-
partners.push({ partner: other, decay });
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
return map;
|
|
1935
|
-
}
|
|
1936
|
-
/**
|
|
1937
|
-
* Get the set of tags that are currently immature for this user.
|
|
1938
|
-
* A tag is immature if the user has interacted with it but hasn't
|
|
1939
|
-
* reached the maturity threshold.
|
|
1940
|
-
*/
|
|
1941
|
-
async getImmatureTags(context) {
|
|
1942
|
-
const immature = /* @__PURE__ */ new Set();
|
|
1943
|
-
try {
|
|
1944
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1945
|
-
const userElo = (0, import_common8.toCourseElo)(courseReg.elo);
|
|
1946
|
-
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1947
|
-
const minElo = this.config.maturityThreshold?.minElo;
|
|
1948
|
-
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
1949
|
-
const minCountForElapsed = minElapsedDays * 2;
|
|
1950
|
-
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
1951
|
-
if (tagElo.count === 0) continue;
|
|
1952
|
-
const belowCount = tagElo.count < minCount;
|
|
1953
|
-
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
1954
|
-
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
1955
|
-
if (belowCount || belowElo || belowElapsed) {
|
|
1956
|
-
immature.add(tagId);
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
} catch {
|
|
1960
|
-
}
|
|
1961
|
-
return immature;
|
|
1962
|
-
}
|
|
1963
|
-
/**
|
|
1964
|
-
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
1965
|
-
* These are the tags we want to avoid introducing.
|
|
1966
|
-
*/
|
|
1967
|
-
getTagsToAvoid(immatureTags) {
|
|
1968
|
-
const avoid = /* @__PURE__ */ new Map();
|
|
1969
|
-
for (const immatureTag of immatureTags) {
|
|
1970
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
1971
|
-
if (partners) {
|
|
1972
|
-
for (const { partner, decay } of partners) {
|
|
1973
|
-
if (!immatureTags.has(partner)) {
|
|
1974
|
-
const existing = avoid.get(partner) ?? 0;
|
|
1975
|
-
avoid.set(partner, Math.max(existing, decay));
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
return avoid;
|
|
1981
|
-
}
|
|
1982
|
-
/**
|
|
1983
|
-
* Compute interference score reduction for a card.
|
|
1984
|
-
* Returns: { multiplier, interfering tags, reason }
|
|
1985
|
-
*/
|
|
1986
|
-
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
1987
|
-
if (tagsToAvoid.size === 0) {
|
|
1988
|
-
return {
|
|
1989
|
-
multiplier: 1,
|
|
1990
|
-
interferingTags: [],
|
|
1991
|
-
reason: "No interference detected"
|
|
1992
|
-
};
|
|
1993
|
-
}
|
|
1994
|
-
let multiplier = 1;
|
|
1995
|
-
const interferingTags = [];
|
|
1996
|
-
for (const tag of cardTags) {
|
|
1997
|
-
const decay = tagsToAvoid.get(tag);
|
|
1998
|
-
if (decay !== void 0) {
|
|
1999
|
-
interferingTags.push(tag);
|
|
2000
|
-
multiplier *= 1 - decay;
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2003
|
-
if (interferingTags.length === 0) {
|
|
2004
|
-
return {
|
|
2005
|
-
multiplier: 1,
|
|
2006
|
-
interferingTags: [],
|
|
2007
|
-
reason: "No interference detected"
|
|
2008
|
-
};
|
|
2009
|
-
}
|
|
2010
|
-
const causingTags = /* @__PURE__ */ new Set();
|
|
2011
|
-
for (const tag of interferingTags) {
|
|
2012
|
-
for (const immatureTag of immatureTags) {
|
|
2013
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
2014
|
-
if (partners?.some((p) => p.partner === tag)) {
|
|
2015
|
-
causingTags.add(immatureTag);
|
|
2016
|
-
}
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
2020
|
-
return { multiplier, interferingTags, reason };
|
|
2021
|
-
}
|
|
2022
|
-
/**
|
|
2023
|
-
* CardFilter.transform implementation.
|
|
2024
|
-
*
|
|
2025
|
-
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
2026
|
-
* immature learnings get reduced scores.
|
|
2027
|
-
*/
|
|
2028
|
-
async transform(cards, context) {
|
|
2029
|
-
const immatureTags = await this.getImmatureTags(context);
|
|
2030
|
-
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
2031
|
-
const adjusted = [];
|
|
2032
|
-
for (const card of cards) {
|
|
2033
|
-
const cardTags = card.tags ?? [];
|
|
2034
|
-
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
2035
|
-
cardTags,
|
|
2036
|
-
tagsToAvoid,
|
|
2037
|
-
immatureTags
|
|
2038
|
-
);
|
|
2039
|
-
const finalScore = card.score * multiplier;
|
|
2040
|
-
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
2041
|
-
adjusted.push({
|
|
2042
|
-
...card,
|
|
2043
|
-
score: finalScore,
|
|
2044
|
-
provenance: [
|
|
2045
|
-
...card.provenance,
|
|
2046
|
-
{
|
|
2047
|
-
strategy: "interferenceMitigator",
|
|
2048
|
-
strategyName: this.strategyName || this.name,
|
|
2049
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
2050
|
-
action,
|
|
2051
|
-
score: finalScore,
|
|
2052
|
-
reason
|
|
2053
|
-
}
|
|
2054
|
-
]
|
|
2055
|
-
});
|
|
2056
|
-
}
|
|
2057
|
-
return adjusted;
|
|
2058
|
-
}
|
|
2059
|
-
/**
|
|
2060
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2061
|
-
*
|
|
2062
|
-
* Use transform() via Pipeline instead.
|
|
2063
|
-
*/
|
|
2064
|
-
async getWeightedCards(_limit) {
|
|
2065
|
-
throw new Error(
|
|
2066
|
-
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2067
|
-
);
|
|
2068
|
-
}
|
|
2069
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2070
|
-
async getNewCards(_n) {
|
|
2071
|
-
return [];
|
|
2072
|
-
}
|
|
2073
|
-
async getPendingReviews() {
|
|
2074
|
-
return [];
|
|
2075
|
-
}
|
|
2076
|
-
};
|
|
2077
|
-
}
|
|
2078
|
-
});
|
|
2079
|
-
|
|
2080
|
-
// src/core/navigators/relativePriority.ts
|
|
2081
|
-
var relativePriority_exports = {};
|
|
2082
|
-
__export(relativePriority_exports, {
|
|
2083
|
-
default: () => RelativePriorityNavigator
|
|
2084
|
-
});
|
|
2085
|
-
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
2086
|
-
var init_relativePriority = __esm({
|
|
2087
|
-
"src/core/navigators/relativePriority.ts"() {
|
|
2088
|
-
"use strict";
|
|
2089
|
-
init_navigators();
|
|
2090
|
-
DEFAULT_PRIORITY = 0.5;
|
|
2091
|
-
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
2092
|
-
DEFAULT_COMBINE_MODE = "max";
|
|
2093
|
-
RelativePriorityNavigator = class extends ContentNavigator {
|
|
2094
|
-
config;
|
|
2095
|
-
_strategyData;
|
|
2096
|
-
/** Human-readable name for CardFilter interface */
|
|
2097
|
-
name;
|
|
2098
|
-
constructor(user, course, _strategyData) {
|
|
2099
|
-
super(user, course, _strategyData);
|
|
2100
|
-
this._strategyData = _strategyData;
|
|
2101
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2102
|
-
this.name = _strategyData.name || "Relative Priority";
|
|
2103
|
-
}
|
|
2104
|
-
parseConfig(serializedData) {
|
|
2105
|
-
try {
|
|
2106
|
-
const parsed = JSON.parse(serializedData);
|
|
2107
|
-
return {
|
|
2108
|
-
tagPriorities: parsed.tagPriorities || {},
|
|
2109
|
-
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
2110
|
-
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2111
|
-
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2112
|
-
};
|
|
2113
|
-
} catch {
|
|
2114
|
-
return {
|
|
2115
|
-
tagPriorities: {},
|
|
2116
|
-
defaultPriority: DEFAULT_PRIORITY,
|
|
2117
|
-
combineMode: DEFAULT_COMBINE_MODE,
|
|
2118
|
-
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2119
|
-
};
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
/**
|
|
2123
|
-
* Look up the priority for a tag.
|
|
2124
|
-
*/
|
|
2125
|
-
getTagPriority(tagId) {
|
|
2126
|
-
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2127
|
-
}
|
|
2128
|
-
/**
|
|
2129
|
-
* Compute combined priority for a card based on its tags.
|
|
2130
|
-
*/
|
|
2131
|
-
computeCardPriority(cardTags) {
|
|
2132
|
-
if (cardTags.length === 0) {
|
|
2133
|
-
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2134
|
-
}
|
|
2135
|
-
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2136
|
-
switch (this.config.combineMode) {
|
|
2137
|
-
case "max":
|
|
2138
|
-
return Math.max(...priorities);
|
|
2139
|
-
case "min":
|
|
2140
|
-
return Math.min(...priorities);
|
|
2141
|
-
case "average":
|
|
2142
|
-
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2143
|
-
default:
|
|
2144
|
-
return Math.max(...priorities);
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
/**
|
|
2148
|
-
* Compute boost factor based on priority.
|
|
2149
|
-
*
|
|
2150
|
-
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2151
|
-
*
|
|
2152
|
-
* This creates a multiplier centered around 1.0:
|
|
2153
|
-
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2154
|
-
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2155
|
-
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2156
|
-
*/
|
|
2157
|
-
computeBoostFactor(priority) {
|
|
2158
|
-
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2159
|
-
return 1 + (priority - 0.5) * influence;
|
|
2160
|
-
}
|
|
2161
|
-
/**
|
|
2162
|
-
* Build human-readable reason for priority adjustment.
|
|
2163
|
-
*/
|
|
2164
|
-
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2165
|
-
if (cardTags.length === 0) {
|
|
2166
|
-
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2167
|
-
}
|
|
2168
|
-
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2169
|
-
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2170
|
-
if (boostFactor === 1) {
|
|
2171
|
-
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2172
|
-
} else if (boostFactor > 1) {
|
|
2173
|
-
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2174
|
-
} else {
|
|
2175
|
-
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
/**
|
|
2179
|
-
* CardFilter.transform implementation.
|
|
2180
|
-
*
|
|
2181
|
-
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2182
|
-
* cards with low-priority tags get reduced scores.
|
|
2183
|
-
*/
|
|
2184
|
-
async transform(cards, _context) {
|
|
2185
|
-
const adjusted = await Promise.all(
|
|
2186
|
-
cards.map(async (card) => {
|
|
2187
|
-
const cardTags = card.tags ?? [];
|
|
2188
|
-
const priority = this.computeCardPriority(cardTags);
|
|
2189
|
-
const boostFactor = this.computeBoostFactor(priority);
|
|
2190
|
-
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2191
|
-
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2192
|
-
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2193
|
-
return {
|
|
2194
|
-
...card,
|
|
2195
|
-
score: finalScore,
|
|
2196
|
-
provenance: [
|
|
2197
|
-
...card.provenance,
|
|
2198
|
-
{
|
|
2199
|
-
strategy: "relativePriority",
|
|
2200
|
-
strategyName: this.strategyName || this.name,
|
|
2201
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2202
|
-
action,
|
|
2203
|
-
score: finalScore,
|
|
2204
|
-
reason
|
|
2205
|
-
}
|
|
2206
|
-
]
|
|
2207
|
-
};
|
|
2208
|
-
})
|
|
2209
|
-
);
|
|
2210
|
-
return adjusted;
|
|
2211
|
-
}
|
|
2212
|
-
/**
|
|
2213
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2214
|
-
*
|
|
2215
|
-
* Use transform() via Pipeline instead.
|
|
2216
|
-
*/
|
|
2217
|
-
async getWeightedCards(_limit) {
|
|
2218
|
-
throw new Error(
|
|
2219
|
-
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2220
|
-
);
|
|
2221
|
-
}
|
|
2222
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2223
|
-
async getNewCards(_n) {
|
|
2224
|
-
return [];
|
|
2225
|
-
}
|
|
2226
|
-
async getPendingReviews() {
|
|
2227
|
-
return [];
|
|
2228
|
-
}
|
|
2229
|
-
};
|
|
2230
|
-
}
|
|
2231
|
-
});
|
|
2232
|
-
|
|
2233
|
-
// src/core/navigators/srs.ts
|
|
2234
|
-
var srs_exports = {};
|
|
2235
|
-
__export(srs_exports, {
|
|
2236
|
-
default: () => SRSNavigator
|
|
2237
|
-
});
|
|
2238
|
-
var import_moment3, SRSNavigator;
|
|
2239
|
-
var init_srs = __esm({
|
|
2240
|
-
"src/core/navigators/srs.ts"() {
|
|
2241
|
-
"use strict";
|
|
2242
|
-
import_moment3 = __toESM(require("moment"), 1);
|
|
2243
|
-
init_navigators();
|
|
2244
|
-
SRSNavigator = class extends ContentNavigator {
|
|
2245
|
-
/** Human-readable name for CardGenerator interface */
|
|
2246
|
-
name;
|
|
2247
|
-
constructor(user, course, strategyData) {
|
|
2248
|
-
super(user, course, strategyData);
|
|
2249
|
-
this.name = strategyData?.name || "SRS";
|
|
2250
|
-
}
|
|
2251
|
-
/**
|
|
2252
|
-
* Get review cards scored by urgency.
|
|
2253
|
-
*
|
|
2254
|
-
* Score formula combines:
|
|
2255
|
-
* - Relative overdueness: hoursOverdue / intervalHours
|
|
2256
|
-
* - Interval recency: exponential decay favoring shorter intervals
|
|
2257
|
-
*
|
|
2258
|
-
* Cards not yet due are excluded (not scored as 0).
|
|
2259
|
-
*
|
|
2260
|
-
* This method supports both the legacy signature (limit only) and the
|
|
2261
|
-
* CardGenerator interface signature (limit, context).
|
|
2262
|
-
*
|
|
2263
|
-
* @param limit - Maximum number of cards to return
|
|
2264
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
2265
|
-
*/
|
|
2266
|
-
async getWeightedCards(limit, _context) {
|
|
2267
|
-
if (!this.user || !this.course) {
|
|
2268
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
1411
|
+
async getWeightedCards(limit, _context) {
|
|
1412
|
+
if (!this.user || !this.course) {
|
|
1413
|
+
throw new Error("SRSNavigator requires user and course to be set");
|
|
2269
1414
|
}
|
|
2270
1415
|
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2271
1416
|
const now = import_moment3.default.utc();
|
|
@@ -2276,6 +1421,7 @@ var init_srs = __esm({
|
|
|
2276
1421
|
cardId: review.cardId,
|
|
2277
1422
|
courseId: review.courseId,
|
|
2278
1423
|
score,
|
|
1424
|
+
reviewID: review._id,
|
|
2279
1425
|
provenance: [
|
|
2280
1426
|
{
|
|
2281
1427
|
strategy: "srs",
|
|
@@ -2288,333 +1434,137 @@ var init_srs = __esm({
|
|
|
2288
1434
|
]
|
|
2289
1435
|
};
|
|
2290
1436
|
});
|
|
1437
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
2291
1438
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
2292
1439
|
}
|
|
2293
1440
|
/**
|
|
2294
1441
|
* Compute urgency score for a review card.
|
|
2295
1442
|
*
|
|
2296
1443
|
* Two factors:
|
|
2297
|
-
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
2298
|
-
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
2299
|
-
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
2300
|
-
*
|
|
2301
|
-
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
2302
|
-
* - 24h interval → ~1.0 (very recent learning)
|
|
2303
|
-
* - 30 days (720h) → ~0.56
|
|
2304
|
-
* - 180 days → ~0.30
|
|
2305
|
-
*
|
|
2306
|
-
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
2307
|
-
* Result range: approximately 0.5 to 0.95
|
|
2308
|
-
*/
|
|
2309
|
-
computeUrgencyScore(review, now) {
|
|
2310
|
-
const scheduledAt = import_moment3.default.utc(review.scheduledAt);
|
|
2311
|
-
const due = import_moment3.default.utc(review.reviewTime);
|
|
2312
|
-
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
2313
|
-
const hoursOverdue = now.diff(due, "hours");
|
|
2314
|
-
const relativeOverdue = hoursOverdue / intervalHours;
|
|
2315
|
-
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
2316
|
-
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
2317
|
-
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
2318
|
-
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
2319
|
-
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
2320
|
-
return { score, reason };
|
|
2321
|
-
}
|
|
2322
|
-
/**
|
|
2323
|
-
* Get pending reviews in legacy format.
|
|
2324
|
-
*
|
|
2325
|
-
* Returns all pending reviews for the course, enriched with session item fields.
|
|
2326
|
-
*/
|
|
2327
|
-
async getPendingReviews() {
|
|
2328
|
-
if (!this.user || !this.course) {
|
|
2329
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
2330
|
-
}
|
|
2331
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2332
|
-
return reviews.map((r) => ({
|
|
2333
|
-
...r,
|
|
2334
|
-
contentSourceType: "course",
|
|
2335
|
-
contentSourceID: this.course.getCourseID(),
|
|
2336
|
-
cardID: r.cardId,
|
|
2337
|
-
courseID: r.courseId,
|
|
2338
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
2339
|
-
reviewID: r._id,
|
|
2340
|
-
status: "review"
|
|
2341
|
-
}));
|
|
2342
|
-
}
|
|
2343
|
-
/**
|
|
2344
|
-
* SRS does not generate new cards.
|
|
2345
|
-
* Use ELONavigator or another generator for new cards.
|
|
2346
|
-
*/
|
|
2347
|
-
async getNewCards(_n) {
|
|
2348
|
-
return [];
|
|
2349
|
-
}
|
|
2350
|
-
};
|
|
2351
|
-
}
|
|
2352
|
-
});
|
|
2353
|
-
|
|
2354
|
-
// src/core/navigators/userGoal.ts
|
|
2355
|
-
var userGoal_exports = {};
|
|
2356
|
-
__export(userGoal_exports, {
|
|
2357
|
-
USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
|
|
2358
|
-
});
|
|
2359
|
-
var USER_GOAL_NAVIGATOR_STUB;
|
|
2360
|
-
var init_userGoal = __esm({
|
|
2361
|
-
"src/core/navigators/userGoal.ts"() {
|
|
2362
|
-
"use strict";
|
|
2363
|
-
USER_GOAL_NAVIGATOR_STUB = true;
|
|
2364
|
-
}
|
|
2365
|
-
});
|
|
2366
|
-
|
|
2367
|
-
// import("./**/*") in src/core/navigators/index.ts
|
|
2368
|
-
var globImport;
|
|
2369
|
-
var init_ = __esm({
|
|
2370
|
-
'import("./**/*") in src/core/navigators/index.ts'() {
|
|
2371
|
-
globImport = __glob({
|
|
2372
|
-
"./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2373
|
-
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2374
|
-
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
2375
|
-
"./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2376
|
-
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2377
|
-
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2378
|
-
"./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2379
|
-
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
|
|
2380
|
-
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2381
|
-
"./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2382
|
-
"./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
|
|
2383
|
-
"./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2384
|
-
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
|
|
2385
|
-
"./inferredPreference.ts": () => Promise.resolve().then(() => (init_inferredPreference(), inferredPreference_exports)),
|
|
2386
|
-
"./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2387
|
-
"./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2388
|
-
"./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2389
|
-
"./userGoal.ts": () => Promise.resolve().then(() => (init_userGoal(), userGoal_exports))
|
|
2390
|
-
});
|
|
2391
|
-
}
|
|
2392
|
-
});
|
|
2393
|
-
|
|
2394
|
-
// src/core/navigators/index.ts
|
|
2395
|
-
var navigators_exports = {};
|
|
2396
|
-
__export(navigators_exports, {
|
|
2397
|
-
ContentNavigator: () => ContentNavigator,
|
|
2398
|
-
NavigatorRole: () => NavigatorRole,
|
|
2399
|
-
NavigatorRoles: () => NavigatorRoles,
|
|
2400
|
-
Navigators: () => Navigators,
|
|
2401
|
-
getCardOrigin: () => getCardOrigin,
|
|
2402
|
-
isFilter: () => isFilter,
|
|
2403
|
-
isGenerator: () => isGenerator
|
|
2404
|
-
});
|
|
2405
|
-
function getCardOrigin(card) {
|
|
2406
|
-
if (card.provenance.length === 0) {
|
|
2407
|
-
throw new Error("Card has no provenance - cannot determine origin");
|
|
2408
|
-
}
|
|
2409
|
-
const firstEntry = card.provenance[0];
|
|
2410
|
-
const reason = firstEntry.reason.toLowerCase();
|
|
2411
|
-
if (reason.includes("failed")) {
|
|
2412
|
-
return "failed";
|
|
2413
|
-
}
|
|
2414
|
-
if (reason.includes("review")) {
|
|
2415
|
-
return "review";
|
|
2416
|
-
}
|
|
2417
|
-
return "new";
|
|
2418
|
-
}
|
|
2419
|
-
function isGenerator(impl) {
|
|
2420
|
-
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
2421
|
-
}
|
|
2422
|
-
function isFilter(impl) {
|
|
2423
|
-
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
2424
|
-
}
|
|
2425
|
-
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
2426
|
-
var init_navigators = __esm({
|
|
2427
|
-
"src/core/navigators/index.ts"() {
|
|
2428
|
-
"use strict";
|
|
2429
|
-
init_logger();
|
|
2430
|
-
init_();
|
|
2431
|
-
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2432
|
-
Navigators2["ELO"] = "elo";
|
|
2433
|
-
Navigators2["SRS"] = "srs";
|
|
2434
|
-
Navigators2["HARDCODED"] = "hardcodedOrder";
|
|
2435
|
-
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2436
|
-
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2437
|
-
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
2438
|
-
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
2439
|
-
return Navigators2;
|
|
2440
|
-
})(Navigators || {});
|
|
2441
|
-
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
2442
|
-
NavigatorRole2["GENERATOR"] = "generator";
|
|
2443
|
-
NavigatorRole2["FILTER"] = "filter";
|
|
2444
|
-
return NavigatorRole2;
|
|
2445
|
-
})(NavigatorRole || {});
|
|
2446
|
-
NavigatorRoles = {
|
|
2447
|
-
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2448
|
-
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
2449
|
-
["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
|
|
2450
|
-
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2451
|
-
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2452
|
-
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
2453
|
-
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
2454
|
-
};
|
|
2455
|
-
ContentNavigator = class {
|
|
2456
|
-
/** User interface for this navigation session */
|
|
2457
|
-
user;
|
|
2458
|
-
/** Course interface for this navigation session */
|
|
2459
|
-
course;
|
|
2460
|
-
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
2461
|
-
strategyName;
|
|
2462
|
-
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
2463
|
-
strategyId;
|
|
2464
|
-
/**
|
|
2465
|
-
* Constructor for standard navigators.
|
|
2466
|
-
* Call this from subclass constructors to initialize common fields.
|
|
2467
|
-
*
|
|
2468
|
-
* Note: CompositeGenerator doesn't use this pattern and should call super() without args.
|
|
2469
|
-
*/
|
|
2470
|
-
constructor(user, course, strategyData) {
|
|
2471
|
-
if (user && course && strategyData) {
|
|
2472
|
-
this.user = user;
|
|
2473
|
-
this.course = course;
|
|
2474
|
-
this.strategyName = strategyData.name;
|
|
2475
|
-
this.strategyId = strategyData._id;
|
|
2476
|
-
}
|
|
2477
|
-
}
|
|
2478
|
-
// ============================================================================
|
|
2479
|
-
// STRATEGY STATE HELPERS
|
|
2480
|
-
// ============================================================================
|
|
2481
|
-
//
|
|
2482
|
-
// These methods allow strategies to persist their own state (user preferences,
|
|
2483
|
-
// learned patterns, temporal tracking) in the user database.
|
|
2484
|
-
//
|
|
2485
|
-
// ============================================================================
|
|
2486
|
-
/**
|
|
2487
|
-
* Unique key identifying this strategy for state storage.
|
|
2488
|
-
*
|
|
2489
|
-
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
2490
|
-
* Override in subclasses if multiple instances of the same strategy type
|
|
2491
|
-
* need separate state storage.
|
|
2492
|
-
*/
|
|
2493
|
-
get strategyKey() {
|
|
2494
|
-
return this.constructor.name;
|
|
2495
|
-
}
|
|
2496
|
-
/**
|
|
2497
|
-
* Get this strategy's persisted state for the current course.
|
|
2498
|
-
*
|
|
2499
|
-
* @returns The strategy's data payload, or null if no state exists
|
|
2500
|
-
* @throws Error if user or course is not initialized
|
|
2501
|
-
*/
|
|
2502
|
-
async getStrategyState() {
|
|
2503
|
-
if (!this.user || !this.course) {
|
|
2504
|
-
throw new Error(
|
|
2505
|
-
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2506
|
-
);
|
|
2507
|
-
}
|
|
2508
|
-
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
2509
|
-
}
|
|
2510
|
-
/**
|
|
2511
|
-
* Persist this strategy's state for the current course.
|
|
2512
|
-
*
|
|
2513
|
-
* @param data - The strategy's data payload to store
|
|
2514
|
-
* @throws Error if user or course is not initialized
|
|
2515
|
-
*/
|
|
2516
|
-
async putStrategyState(data) {
|
|
2517
|
-
if (!this.user || !this.course) {
|
|
2518
|
-
throw new Error(
|
|
2519
|
-
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2520
|
-
);
|
|
2521
|
-
}
|
|
2522
|
-
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
2523
|
-
}
|
|
2524
|
-
/**
|
|
2525
|
-
* Factory method to create navigator instances dynamically.
|
|
2526
|
-
*
|
|
2527
|
-
* @param user - User interface
|
|
2528
|
-
* @param course - Course interface
|
|
2529
|
-
* @param strategyData - Strategy configuration document
|
|
2530
|
-
* @returns the runtime object used to steer a study session.
|
|
2531
|
-
*/
|
|
2532
|
-
static async create(user, course, strategyData) {
|
|
2533
|
-
const implementingClass = strategyData.implementingClass;
|
|
2534
|
-
let NavigatorImpl;
|
|
2535
|
-
const variations = [".ts", ".js", ""];
|
|
2536
|
-
for (const ext of variations) {
|
|
2537
|
-
try {
|
|
2538
|
-
const module2 = await globImport(`./${implementingClass}${ext}`);
|
|
2539
|
-
NavigatorImpl = module2.default;
|
|
2540
|
-
break;
|
|
2541
|
-
} catch (e) {
|
|
2542
|
-
logger.debug(`Failed to load with extension ${ext}:`, e);
|
|
2543
|
-
}
|
|
2544
|
-
}
|
|
2545
|
-
if (!NavigatorImpl) {
|
|
2546
|
-
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
2547
|
-
}
|
|
2548
|
-
return new NavigatorImpl(user, course, strategyData);
|
|
2549
|
-
}
|
|
2550
|
-
/**
|
|
2551
|
-
* Get cards with suitability scores and provenance trails.
|
|
2552
|
-
*
|
|
2553
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
2554
|
-
*
|
|
2555
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
2556
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
2557
|
-
* documenting how strategies contributed to the final score.
|
|
2558
|
-
*
|
|
2559
|
-
* ## For Generators
|
|
2560
|
-
* Override this method to generate candidates and compute scores based on
|
|
2561
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2562
|
-
* initial provenance entry with action='generated'.
|
|
2563
|
-
*
|
|
2564
|
-
* ## Default Implementation
|
|
2565
|
-
* The base class provides a backward-compatible default that:
|
|
2566
|
-
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
2567
|
-
* 2. Assigns score=1.0 to all cards
|
|
2568
|
-
* 3. Creates minimal provenance from legacy methods
|
|
2569
|
-
* 4. Returns combined results up to limit
|
|
2570
|
-
*
|
|
2571
|
-
* This allows existing strategies to work without modification while
|
|
2572
|
-
* new strategies can override with proper scoring and provenance.
|
|
2573
|
-
*
|
|
2574
|
-
* @param limit - Maximum cards to return
|
|
2575
|
-
* @returns Cards sorted by score descending, with provenance trails
|
|
2576
|
-
*/
|
|
2577
|
-
async getWeightedCards(limit) {
|
|
2578
|
-
const newCards = await this.getNewCards(limit);
|
|
2579
|
-
const reviews = await this.getPendingReviews();
|
|
2580
|
-
const weighted = [
|
|
2581
|
-
...newCards.map((c) => ({
|
|
2582
|
-
cardId: c.cardID,
|
|
2583
|
-
courseId: c.courseID,
|
|
2584
|
-
score: 1,
|
|
2585
|
-
provenance: [
|
|
2586
|
-
{
|
|
2587
|
-
strategy: "legacy",
|
|
2588
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2589
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2590
|
-
action: "generated",
|
|
2591
|
-
score: 1,
|
|
2592
|
-
reason: "Generated via legacy getNewCards(), new card"
|
|
2593
|
-
}
|
|
2594
|
-
]
|
|
2595
|
-
})),
|
|
2596
|
-
...reviews.map((r) => ({
|
|
2597
|
-
cardId: r.cardID,
|
|
2598
|
-
courseId: r.courseID,
|
|
2599
|
-
score: 1,
|
|
2600
|
-
provenance: [
|
|
2601
|
-
{
|
|
2602
|
-
strategy: "legacy",
|
|
2603
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2604
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2605
|
-
action: "generated",
|
|
2606
|
-
score: 1,
|
|
2607
|
-
reason: "Generated via legacy getPendingReviews(), review"
|
|
2608
|
-
}
|
|
2609
|
-
]
|
|
2610
|
-
}))
|
|
2611
|
-
];
|
|
2612
|
-
return weighted.slice(0, limit);
|
|
1444
|
+
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
1445
|
+
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
1446
|
+
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
1447
|
+
*
|
|
1448
|
+
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
1449
|
+
* - 24h interval → ~1.0 (very recent learning)
|
|
1450
|
+
* - 30 days (720h) → ~0.56
|
|
1451
|
+
* - 180 days → ~0.30
|
|
1452
|
+
*
|
|
1453
|
+
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
1454
|
+
* Result range: approximately 0.5 to 0.95
|
|
1455
|
+
*/
|
|
1456
|
+
computeUrgencyScore(review, now) {
|
|
1457
|
+
const scheduledAt = import_moment3.default.utc(review.scheduledAt);
|
|
1458
|
+
const due = import_moment3.default.utc(review.reviewTime);
|
|
1459
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
1460
|
+
const hoursOverdue = now.diff(due, "hours");
|
|
1461
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
1462
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1463
|
+
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1464
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1465
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
1466
|
+
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
1467
|
+
return { score, reason };
|
|
2613
1468
|
}
|
|
2614
1469
|
};
|
|
2615
1470
|
}
|
|
2616
1471
|
});
|
|
2617
1472
|
|
|
1473
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1474
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1475
|
+
const normalizedDistance = distance / halfLife;
|
|
1476
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1477
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1478
|
+
}
|
|
1479
|
+
function createEloDistanceFilter(config) {
|
|
1480
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1481
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1482
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1483
|
+
return {
|
|
1484
|
+
name: "ELO Distance Filter",
|
|
1485
|
+
async transform(cards, context) {
|
|
1486
|
+
const { course, userElo } = context;
|
|
1487
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1488
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1489
|
+
return cards.map((card, i) => {
|
|
1490
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1491
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1492
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1493
|
+
const newScore = card.score * multiplier;
|
|
1494
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1495
|
+
return {
|
|
1496
|
+
...card,
|
|
1497
|
+
score: newScore,
|
|
1498
|
+
provenance: [
|
|
1499
|
+
...card.provenance,
|
|
1500
|
+
{
|
|
1501
|
+
strategy: "eloDistance",
|
|
1502
|
+
strategyName: "ELO Distance Filter",
|
|
1503
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1504
|
+
action,
|
|
1505
|
+
score: newScore,
|
|
1506
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1507
|
+
}
|
|
1508
|
+
]
|
|
1509
|
+
};
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1515
|
+
var init_eloDistance = __esm({
|
|
1516
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1517
|
+
"use strict";
|
|
1518
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1519
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1520
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
// src/core/navigators/defaults.ts
|
|
1525
|
+
function createDefaultEloStrategy(courseId) {
|
|
1526
|
+
return {
|
|
1527
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1528
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1529
|
+
name: "ELO (default)",
|
|
1530
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
1531
|
+
implementingClass: "elo" /* ELO */,
|
|
1532
|
+
course: courseId,
|
|
1533
|
+
serializedData: ""
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
function createDefaultSrsStrategy(courseId) {
|
|
1537
|
+
return {
|
|
1538
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
1539
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1540
|
+
name: "SRS (default)",
|
|
1541
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
1542
|
+
implementingClass: "srs" /* SRS */,
|
|
1543
|
+
course: courseId,
|
|
1544
|
+
serializedData: ""
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
function createDefaultPipeline(user, course) {
|
|
1548
|
+
const courseId = course.getCourseID();
|
|
1549
|
+
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
1550
|
+
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
1551
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
1552
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
1553
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
1554
|
+
}
|
|
1555
|
+
var init_defaults = __esm({
|
|
1556
|
+
"src/core/navigators/defaults.ts"() {
|
|
1557
|
+
"use strict";
|
|
1558
|
+
init_navigators();
|
|
1559
|
+
init_Pipeline();
|
|
1560
|
+
init_CompositeGenerator();
|
|
1561
|
+
init_elo();
|
|
1562
|
+
init_srs();
|
|
1563
|
+
init_eloDistance();
|
|
1564
|
+
init_types_legacy();
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
|
|
2618
1568
|
// src/impl/couch/courseDB.ts
|
|
2619
1569
|
function randIntWeightedTowardZero(n) {
|
|
2620
1570
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -2691,11 +1641,11 @@ ${JSON.stringify(config)}
|
|
|
2691
1641
|
function isSuccessRow(row) {
|
|
2692
1642
|
return "doc" in row && row.doc !== null && row.doc !== void 0;
|
|
2693
1643
|
}
|
|
2694
|
-
var
|
|
1644
|
+
var import_common7, CourseDB;
|
|
2695
1645
|
var init_courseDB = __esm({
|
|
2696
1646
|
"src/impl/couch/courseDB.ts"() {
|
|
2697
1647
|
"use strict";
|
|
2698
|
-
|
|
1648
|
+
import_common7 = require("@vue-skuilder/common");
|
|
2699
1649
|
init_couch();
|
|
2700
1650
|
init_updateQueue();
|
|
2701
1651
|
init_types_legacy();
|
|
@@ -2704,12 +1654,8 @@ var init_courseDB = __esm({
|
|
|
2704
1654
|
init_courseAPI();
|
|
2705
1655
|
init_courseLookupDB();
|
|
2706
1656
|
init_navigators();
|
|
2707
|
-
init_Pipeline();
|
|
2708
1657
|
init_PipelineAssembler();
|
|
2709
|
-
|
|
2710
|
-
init_elo();
|
|
2711
|
-
init_srs();
|
|
2712
|
-
init_eloDistance();
|
|
1658
|
+
init_defaults();
|
|
2713
1659
|
CourseDB = class {
|
|
2714
1660
|
// private log(msg: string): void {
|
|
2715
1661
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
@@ -2776,14 +1722,14 @@ var init_courseDB = __esm({
|
|
|
2776
1722
|
docs.rows.forEach((r) => {
|
|
2777
1723
|
if (isSuccessRow(r)) {
|
|
2778
1724
|
if (r.doc && r.doc.elo) {
|
|
2779
|
-
ret.push((0,
|
|
1725
|
+
ret.push((0, import_common7.toCourseElo)(r.doc.elo));
|
|
2780
1726
|
} else {
|
|
2781
1727
|
logger.warn("no elo data for card: " + r.id);
|
|
2782
|
-
ret.push((0,
|
|
1728
|
+
ret.push((0, import_common7.blankCourseElo)());
|
|
2783
1729
|
}
|
|
2784
1730
|
} else {
|
|
2785
1731
|
logger.warn("no elo data for card: " + JSON.stringify(r));
|
|
2786
|
-
ret.push((0,
|
|
1732
|
+
ret.push((0, import_common7.blankCourseElo)());
|
|
2787
1733
|
}
|
|
2788
1734
|
});
|
|
2789
1735
|
return ret;
|
|
@@ -2978,7 +1924,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2978
1924
|
async getCourseTagStubs() {
|
|
2979
1925
|
return getCourseTagStubs(this.id);
|
|
2980
1926
|
}
|
|
2981
|
-
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0,
|
|
1927
|
+
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common7.blankCourseElo)()) {
|
|
2982
1928
|
try {
|
|
2983
1929
|
const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
|
|
2984
1930
|
if (resp.ok) {
|
|
@@ -2987,19 +1933,19 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2987
1933
|
`[courseDB.addNote] Note added but card creation failed: ${resp.cardCreationError}`
|
|
2988
1934
|
);
|
|
2989
1935
|
return {
|
|
2990
|
-
status:
|
|
1936
|
+
status: import_common7.Status.error,
|
|
2991
1937
|
message: `Note was added but no cards were created: ${resp.cardCreationError}`,
|
|
2992
1938
|
id: resp.id
|
|
2993
1939
|
};
|
|
2994
1940
|
}
|
|
2995
1941
|
return {
|
|
2996
|
-
status:
|
|
1942
|
+
status: import_common7.Status.ok,
|
|
2997
1943
|
message: "",
|
|
2998
1944
|
id: resp.id
|
|
2999
1945
|
};
|
|
3000
1946
|
} else {
|
|
3001
1947
|
return {
|
|
3002
|
-
status:
|
|
1948
|
+
status: import_common7.Status.error,
|
|
3003
1949
|
message: "Unexpected error adding note"
|
|
3004
1950
|
};
|
|
3005
1951
|
}
|
|
@@ -3011,7 +1957,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3011
1957
|
message: ${err.message}`
|
|
3012
1958
|
);
|
|
3013
1959
|
return {
|
|
3014
|
-
status:
|
|
1960
|
+
status: import_common7.Status.error,
|
|
3015
1961
|
message: `Error adding note to course. ${e.reason || err.message}`
|
|
3016
1962
|
};
|
|
3017
1963
|
}
|
|
@@ -3078,7 +2024,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3078
2024
|
logger.debug(
|
|
3079
2025
|
"[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
3080
2026
|
);
|
|
3081
|
-
return
|
|
2027
|
+
return createDefaultPipeline(user, this);
|
|
3082
2028
|
}
|
|
3083
2029
|
const assembler = new PipelineAssembler();
|
|
3084
2030
|
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
@@ -3091,7 +2037,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3091
2037
|
}
|
|
3092
2038
|
if (!pipeline) {
|
|
3093
2039
|
logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
|
|
3094
|
-
return
|
|
2040
|
+
return createDefaultPipeline(user, this);
|
|
3095
2041
|
}
|
|
3096
2042
|
logger.debug(
|
|
3097
2043
|
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
@@ -3102,69 +2048,12 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3102
2048
|
throw e;
|
|
3103
2049
|
}
|
|
3104
2050
|
}
|
|
3105
|
-
makeDefaultEloStrategy() {
|
|
3106
|
-
return {
|
|
3107
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
3108
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3109
|
-
name: "ELO (default)",
|
|
3110
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
3111
|
-
implementingClass: "elo" /* ELO */,
|
|
3112
|
-
course: this.id,
|
|
3113
|
-
serializedData: ""
|
|
3114
|
-
};
|
|
3115
|
-
}
|
|
3116
|
-
makeDefaultSrsStrategy() {
|
|
3117
|
-
return {
|
|
3118
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3119
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3120
|
-
name: "SRS (default)",
|
|
3121
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
3122
|
-
implementingClass: "srs" /* SRS */,
|
|
3123
|
-
course: this.id,
|
|
3124
|
-
serializedData: ""
|
|
3125
|
-
};
|
|
3126
|
-
}
|
|
3127
|
-
/**
|
|
3128
|
-
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
3129
|
-
*
|
|
3130
|
-
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
3131
|
-
* - ELO generator: scores new cards by skill proximity
|
|
3132
|
-
* - SRS generator: scores reviews by overdueness and interval recency
|
|
3133
|
-
* - ELO distance filter: penalizes cards far from user's current level
|
|
3134
|
-
*/
|
|
3135
|
-
createDefaultPipeline(user) {
|
|
3136
|
-
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
3137
|
-
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
3138
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3139
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
3140
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
3141
|
-
}
|
|
3142
2051
|
////////////////////////////////////
|
|
3143
2052
|
// END NavigationStrategyManager implementation
|
|
3144
2053
|
////////////////////////////////////
|
|
3145
2054
|
////////////////////////////////////
|
|
3146
2055
|
// StudyContentSource implementation
|
|
3147
2056
|
////////////////////////////////////
|
|
3148
|
-
async getNewCards(limit = 99) {
|
|
3149
|
-
const u = await this._getCurrentUser();
|
|
3150
|
-
try {
|
|
3151
|
-
const navigator = await this.createNavigator(u);
|
|
3152
|
-
return navigator.getNewCards(limit);
|
|
3153
|
-
} catch (e) {
|
|
3154
|
-
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
3155
|
-
throw e;
|
|
3156
|
-
}
|
|
3157
|
-
}
|
|
3158
|
-
async getPendingReviews() {
|
|
3159
|
-
const u = await this._getCurrentUser();
|
|
3160
|
-
try {
|
|
3161
|
-
const navigator = await this.createNavigator(u);
|
|
3162
|
-
return navigator.getPendingReviews();
|
|
3163
|
-
} catch (e) {
|
|
3164
|
-
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
3165
|
-
throw e;
|
|
3166
|
-
}
|
|
3167
|
-
}
|
|
3168
2057
|
/**
|
|
3169
2058
|
* Get cards with suitability scores for presentation.
|
|
3170
2059
|
*
|
|
@@ -3196,7 +2085,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3196
2085
|
const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
|
|
3197
2086
|
return c.courseID === this.id;
|
|
3198
2087
|
});
|
|
3199
|
-
targetElo = (0,
|
|
2088
|
+
targetElo = (0, import_common7.EloToNumber)(courseDoc.elo);
|
|
3200
2089
|
} catch {
|
|
3201
2090
|
targetElo = 1e3;
|
|
3202
2091
|
}
|
|
@@ -3403,79 +2292,27 @@ var init_classroomDB2 = __esm({
|
|
|
3403
2292
|
setChangeFcn(f) {
|
|
3404
2293
|
void this.userMessages.on("change", f);
|
|
3405
2294
|
}
|
|
3406
|
-
async getPendingReviews() {
|
|
3407
|
-
const u = this._user;
|
|
3408
|
-
return (await u.getPendingReviews()).filter((r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id).map((r) => {
|
|
3409
|
-
return {
|
|
3410
|
-
...r,
|
|
3411
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
3412
|
-
courseID: r.courseId,
|
|
3413
|
-
cardID: r.cardId,
|
|
3414
|
-
contentSourceType: "classroom",
|
|
3415
|
-
contentSourceID: this._id,
|
|
3416
|
-
reviewID: r._id,
|
|
3417
|
-
status: "review"
|
|
3418
|
-
};
|
|
3419
|
-
});
|
|
3420
|
-
}
|
|
3421
|
-
async getNewCards() {
|
|
3422
|
-
const activeCards = await this._user.getActiveCards();
|
|
3423
|
-
const now = import_moment4.default.utc();
|
|
3424
|
-
const assigned = await this.getAssignedContent();
|
|
3425
|
-
const due = assigned.filter((c) => now.isAfter(import_moment4.default.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
3426
|
-
logger.info(`Due content: ${JSON.stringify(due)}`);
|
|
3427
|
-
let ret = [];
|
|
3428
|
-
for (let i = 0; i < due.length; i++) {
|
|
3429
|
-
const content = due[i];
|
|
3430
|
-
if (content.type === "course") {
|
|
3431
|
-
const db = new CourseDB(content.courseID, async () => this._user);
|
|
3432
|
-
ret = ret.concat(await db.getNewCards());
|
|
3433
|
-
} else if (content.type === "tag") {
|
|
3434
|
-
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
3435
|
-
ret = ret.concat(
|
|
3436
|
-
tagDoc.taggedCards.map((c) => {
|
|
3437
|
-
return {
|
|
3438
|
-
courseID: content.courseID,
|
|
3439
|
-
cardID: c,
|
|
3440
|
-
qualifiedID: `${content.courseID}-${c}`,
|
|
3441
|
-
contentSourceType: "classroom",
|
|
3442
|
-
contentSourceID: this._id,
|
|
3443
|
-
status: "new"
|
|
3444
|
-
};
|
|
3445
|
-
})
|
|
3446
|
-
);
|
|
3447
|
-
} else if (content.type === "card") {
|
|
3448
|
-
ret.push(await getCourseDB2(content.courseID).get(content.cardID));
|
|
3449
|
-
}
|
|
3450
|
-
}
|
|
3451
|
-
logger.info(
|
|
3452
|
-
`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
|
|
3453
|
-
);
|
|
3454
|
-
return ret.filter((c) => {
|
|
3455
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
3456
|
-
return false;
|
|
3457
|
-
} else {
|
|
3458
|
-
return true;
|
|
3459
|
-
}
|
|
3460
|
-
});
|
|
3461
|
-
}
|
|
3462
2295
|
/**
|
|
3463
2296
|
* Get cards with suitability scores for presentation.
|
|
3464
2297
|
*
|
|
3465
|
-
*
|
|
3466
|
-
*
|
|
3467
|
-
* support pluggable navigation strategies.
|
|
2298
|
+
* Gathers new cards from assigned content (courses, tags, cards) and
|
|
2299
|
+
* pending reviews scheduled for this classroom. Assigns score=1.0 to all.
|
|
3468
2300
|
*
|
|
3469
2301
|
* @param limit - Maximum number of cards to return
|
|
3470
2302
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3471
2303
|
*/
|
|
3472
2304
|
async getWeightedCards(limit) {
|
|
3473
|
-
const
|
|
3474
|
-
const
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
2305
|
+
const weighted = [];
|
|
2306
|
+
const allUserReviews = await this._user.getPendingReviews();
|
|
2307
|
+
const classroomReviews = allUserReviews.filter(
|
|
2308
|
+
(r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id
|
|
2309
|
+
);
|
|
2310
|
+
for (const r of classroomReviews) {
|
|
2311
|
+
weighted.push({
|
|
2312
|
+
cardId: r.cardId,
|
|
2313
|
+
courseId: r.courseId,
|
|
3478
2314
|
score: 1,
|
|
2315
|
+
reviewID: r._id,
|
|
3479
2316
|
provenance: [
|
|
3480
2317
|
{
|
|
3481
2318
|
strategy: "classroom",
|
|
@@ -3483,27 +2320,84 @@ var init_classroomDB2 = __esm({
|
|
|
3483
2320
|
strategyId: "CLASSROOM",
|
|
3484
2321
|
action: "generated",
|
|
3485
2322
|
score: 1,
|
|
3486
|
-
reason: "Classroom
|
|
2323
|
+
reason: "Classroom scheduled review"
|
|
3487
2324
|
}
|
|
3488
2325
|
]
|
|
3489
|
-
})
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
const activeCards = await this._user.getActiveCards();
|
|
2329
|
+
const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
2330
|
+
const now = import_moment4.default.utc();
|
|
2331
|
+
const assigned = await this.getAssignedContent();
|
|
2332
|
+
const due = assigned.filter((c) => now.isAfter(import_moment4.default.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
2333
|
+
logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
|
|
2334
|
+
for (const content of due) {
|
|
2335
|
+
if (content.type === "course") {
|
|
2336
|
+
const db = new CourseDB(content.courseID, async () => this._user);
|
|
2337
|
+
const courseCards = await db.getWeightedCards(limit);
|
|
2338
|
+
for (const card of courseCards) {
|
|
2339
|
+
if (!activeCardIds.has(card.cardId)) {
|
|
2340
|
+
weighted.push({
|
|
2341
|
+
...card,
|
|
2342
|
+
provenance: [
|
|
2343
|
+
...card.provenance,
|
|
2344
|
+
{
|
|
2345
|
+
strategy: "classroom",
|
|
2346
|
+
strategyName: "Classroom",
|
|
2347
|
+
strategyId: "CLASSROOM",
|
|
2348
|
+
action: "passed",
|
|
2349
|
+
score: card.score,
|
|
2350
|
+
reason: `Assigned via classroom from course ${content.courseID}`
|
|
2351
|
+
}
|
|
2352
|
+
]
|
|
2353
|
+
});
|
|
3502
2354
|
}
|
|
3503
|
-
|
|
3504
|
-
})
|
|
3505
|
-
|
|
3506
|
-
|
|
2355
|
+
}
|
|
2356
|
+
} else if (content.type === "tag") {
|
|
2357
|
+
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
2358
|
+
for (const cardId of tagDoc.taggedCards) {
|
|
2359
|
+
if (!activeCardIds.has(cardId)) {
|
|
2360
|
+
weighted.push({
|
|
2361
|
+
cardId,
|
|
2362
|
+
courseId: content.courseID,
|
|
2363
|
+
score: 1,
|
|
2364
|
+
provenance: [
|
|
2365
|
+
{
|
|
2366
|
+
strategy: "classroom",
|
|
2367
|
+
strategyName: "Classroom",
|
|
2368
|
+
strategyId: "CLASSROOM",
|
|
2369
|
+
action: "generated",
|
|
2370
|
+
score: 1,
|
|
2371
|
+
reason: `Classroom assigned tag: ${content.tagID}, new card`
|
|
2372
|
+
}
|
|
2373
|
+
]
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
} else if (content.type === "card") {
|
|
2378
|
+
if (!activeCardIds.has(content.cardID)) {
|
|
2379
|
+
weighted.push({
|
|
2380
|
+
cardId: content.cardID,
|
|
2381
|
+
courseId: content.courseID,
|
|
2382
|
+
score: 1,
|
|
2383
|
+
provenance: [
|
|
2384
|
+
{
|
|
2385
|
+
strategy: "classroom",
|
|
2386
|
+
strategyName: "Classroom",
|
|
2387
|
+
strategyId: "CLASSROOM",
|
|
2388
|
+
action: "generated",
|
|
2389
|
+
score: 1,
|
|
2390
|
+
reason: "Classroom assigned card, new card"
|
|
2391
|
+
}
|
|
2392
|
+
]
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
logger.info(
|
|
2398
|
+
`[StudentClassroomDB] New cards from classroom ${this._cfg.name}: ${weighted.length} total (reviews + new)`
|
|
2399
|
+
);
|
|
2400
|
+
return weighted.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
3507
2401
|
}
|
|
3508
2402
|
};
|
|
3509
2403
|
}
|
|
@@ -3534,14 +2428,14 @@ var init_auth = __esm({
|
|
|
3534
2428
|
});
|
|
3535
2429
|
|
|
3536
2430
|
// src/impl/couch/CouchDBSyncStrategy.ts
|
|
3537
|
-
var
|
|
2431
|
+
var import_common8;
|
|
3538
2432
|
var init_CouchDBSyncStrategy = __esm({
|
|
3539
2433
|
"src/impl/couch/CouchDBSyncStrategy.ts"() {
|
|
3540
2434
|
"use strict";
|
|
3541
2435
|
init_factory();
|
|
3542
2436
|
init_types_legacy();
|
|
3543
2437
|
init_logger();
|
|
3544
|
-
|
|
2438
|
+
import_common8 = require("@vue-skuilder/common");
|
|
3545
2439
|
init_common();
|
|
3546
2440
|
init_pouchdb_setup();
|
|
3547
2441
|
init_couch();
|
|
@@ -3727,13 +2621,13 @@ async function dropUserFromClassroom(user, classID) {
|
|
|
3727
2621
|
async function getUserClassrooms(user) {
|
|
3728
2622
|
return getOrCreateClassroomRegistrationsDoc(user);
|
|
3729
2623
|
}
|
|
3730
|
-
var
|
|
2624
|
+
var import_common10, import_moment6, log3, BaseUser, userCoursesDoc, userClassroomsDoc;
|
|
3731
2625
|
var init_BaseUserDB = __esm({
|
|
3732
2626
|
"src/impl/common/BaseUserDB.ts"() {
|
|
3733
2627
|
"use strict";
|
|
3734
2628
|
init_core();
|
|
3735
2629
|
init_util();
|
|
3736
|
-
|
|
2630
|
+
import_common10 = require("@vue-skuilder/common");
|
|
3737
2631
|
import_moment6 = __toESM(require("moment"), 1);
|
|
3738
2632
|
init_types_legacy();
|
|
3739
2633
|
init_logger();
|
|
@@ -3783,7 +2677,7 @@ Currently logged-in as ${this._username}.`
|
|
|
3783
2677
|
);
|
|
3784
2678
|
}
|
|
3785
2679
|
const result = await this.syncStrategy.createAccount(username, password);
|
|
3786
|
-
if (result.status ===
|
|
2680
|
+
if (result.status === import_common10.Status.ok) {
|
|
3787
2681
|
log3(`Account created successfully, updating username to ${username}`);
|
|
3788
2682
|
this._username = username;
|
|
3789
2683
|
try {
|
|
@@ -3825,7 +2719,7 @@ Currently logged-in as ${this._username}.`
|
|
|
3825
2719
|
async resetUserData() {
|
|
3826
2720
|
if (this.syncStrategy.canAuthenticate()) {
|
|
3827
2721
|
return {
|
|
3828
|
-
status:
|
|
2722
|
+
status: import_common10.Status.error,
|
|
3829
2723
|
error: "Reset user data is only available for local-only mode. Use logout instead for remote sync."
|
|
3830
2724
|
};
|
|
3831
2725
|
}
|
|
@@ -3844,11 +2738,11 @@ Currently logged-in as ${this._username}.`
|
|
|
3844
2738
|
await localDB.bulkDocs(docsToDelete);
|
|
3845
2739
|
}
|
|
3846
2740
|
await this.init();
|
|
3847
|
-
return { status:
|
|
2741
|
+
return { status: import_common10.Status.ok };
|
|
3848
2742
|
} catch (error) {
|
|
3849
2743
|
logger.error("Failed to reset user data:", error);
|
|
3850
2744
|
return {
|
|
3851
|
-
status:
|
|
2745
|
+
status: import_common10.Status.error,
|
|
3852
2746
|
error: error instanceof Error ? error.message : "Unknown error during reset"
|
|
3853
2747
|
};
|
|
3854
2748
|
}
|
|
@@ -4628,11 +3522,11 @@ var init_factory = __esm({
|
|
|
4628
3522
|
});
|
|
4629
3523
|
|
|
4630
3524
|
// src/study/TagFilteredContentSource.ts
|
|
4631
|
-
var
|
|
3525
|
+
var import_common12, TagFilteredContentSource;
|
|
4632
3526
|
var init_TagFilteredContentSource = __esm({
|
|
4633
3527
|
"src/study/TagFilteredContentSource.ts"() {
|
|
4634
3528
|
"use strict";
|
|
4635
|
-
|
|
3529
|
+
import_common12 = require("@vue-skuilder/common");
|
|
4636
3530
|
init_courseDB();
|
|
4637
3531
|
init_logger();
|
|
4638
3532
|
TagFilteredContentSource = class {
|
|
@@ -4708,108 +3602,71 @@ var init_TagFilteredContentSource = __esm({
|
|
|
4708
3602
|
return finalCardIds;
|
|
4709
3603
|
}
|
|
4710
3604
|
/**
|
|
4711
|
-
*
|
|
3605
|
+
* Get cards with suitability scores for presentation.
|
|
3606
|
+
*
|
|
3607
|
+
* Filters cards by tag inclusion/exclusion and assigns score=1.0 to all.
|
|
3608
|
+
* TagFilteredContentSource does not currently support pluggable navigation
|
|
3609
|
+
* strategies - it returns flat-scored candidates.
|
|
3610
|
+
*
|
|
3611
|
+
* @param limit - Maximum number of cards to return
|
|
3612
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
4712
3613
|
*/
|
|
4713
|
-
async
|
|
4714
|
-
if (!(0,
|
|
4715
|
-
logger.warn("[TagFilteredContentSource]
|
|
3614
|
+
async getWeightedCards(limit) {
|
|
3615
|
+
if (!(0, import_common12.hasActiveFilter)(this.filter)) {
|
|
3616
|
+
logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
|
|
4716
3617
|
return [];
|
|
4717
3618
|
}
|
|
4718
3619
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
4719
3620
|
const activeCards = await this.user.getActiveCards();
|
|
4720
3621
|
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
4721
|
-
const
|
|
3622
|
+
const newCardWeighted = [];
|
|
4722
3623
|
for (const cardId of eligibleCardIds) {
|
|
4723
3624
|
if (!activeCardIds.has(cardId)) {
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
3625
|
+
newCardWeighted.push({
|
|
3626
|
+
cardId,
|
|
3627
|
+
courseId: this.courseId,
|
|
3628
|
+
score: 1,
|
|
3629
|
+
provenance: [
|
|
3630
|
+
{
|
|
3631
|
+
strategy: "tagFilter",
|
|
3632
|
+
strategyName: "Tag Filter",
|
|
3633
|
+
strategyId: "TAG_FILTER",
|
|
3634
|
+
action: "generated",
|
|
3635
|
+
score: 1,
|
|
3636
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
3637
|
+
}
|
|
3638
|
+
]
|
|
4730
3639
|
});
|
|
4731
3640
|
}
|
|
4732
|
-
if (
|
|
3641
|
+
if (newCardWeighted.length >= limit) {
|
|
4733
3642
|
break;
|
|
4734
3643
|
}
|
|
4735
3644
|
}
|
|
4736
|
-
logger.info(
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
/**
|
|
4740
|
-
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
4741
|
-
*/
|
|
4742
|
-
async getPendingReviews() {
|
|
4743
|
-
if (!(0, import_common14.hasActiveFilter)(this.filter)) {
|
|
4744
|
-
logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
|
|
4745
|
-
return [];
|
|
4746
|
-
}
|
|
4747
|
-
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
3645
|
+
logger.info(
|
|
3646
|
+
`[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
|
|
3647
|
+
);
|
|
4748
3648
|
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
4749
|
-
const filteredReviews = allReviews.filter((review) =>
|
|
4750
|
-
return eligibleCardIds.has(review.cardId);
|
|
4751
|
-
});
|
|
3649
|
+
const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
|
|
4752
3650
|
logger.info(
|
|
4753
3651
|
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
|
|
4754
3652
|
);
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
contentSourceType: "course",
|
|
4760
|
-
contentSourceID: this.courseId,
|
|
3653
|
+
const reviewWeighted = filteredReviews.map((r) => ({
|
|
3654
|
+
cardId: r.cardId,
|
|
3655
|
+
courseId: r.courseId,
|
|
3656
|
+
score: 1,
|
|
4761
3657
|
reviewID: r._id,
|
|
4762
|
-
|
|
3658
|
+
provenance: [
|
|
3659
|
+
{
|
|
3660
|
+
strategy: "tagFilter",
|
|
3661
|
+
strategyName: "Tag Filter",
|
|
3662
|
+
strategyId: "TAG_FILTER",
|
|
3663
|
+
action: "generated",
|
|
3664
|
+
score: 1,
|
|
3665
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
3666
|
+
}
|
|
3667
|
+
]
|
|
4763
3668
|
}));
|
|
4764
|
-
|
|
4765
|
-
/**
|
|
4766
|
-
* Get cards with suitability scores for presentation.
|
|
4767
|
-
*
|
|
4768
|
-
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
4769
|
-
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
4770
|
-
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
4771
|
-
*
|
|
4772
|
-
* @param limit - Maximum number of cards to return
|
|
4773
|
-
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
4774
|
-
*/
|
|
4775
|
-
async getWeightedCards(limit) {
|
|
4776
|
-
const [newCards, reviews] = await Promise.all([
|
|
4777
|
-
this.getNewCards(limit),
|
|
4778
|
-
this.getPendingReviews()
|
|
4779
|
-
]);
|
|
4780
|
-
const weighted = [
|
|
4781
|
-
...reviews.map((r) => ({
|
|
4782
|
-
cardId: r.cardID,
|
|
4783
|
-
courseId: r.courseID,
|
|
4784
|
-
score: 1,
|
|
4785
|
-
provenance: [
|
|
4786
|
-
{
|
|
4787
|
-
strategy: "tagFilter",
|
|
4788
|
-
strategyName: "Tag Filter",
|
|
4789
|
-
strategyId: "TAG_FILTER",
|
|
4790
|
-
action: "generated",
|
|
4791
|
-
score: 1,
|
|
4792
|
-
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
4793
|
-
}
|
|
4794
|
-
]
|
|
4795
|
-
})),
|
|
4796
|
-
...newCards.map((c) => ({
|
|
4797
|
-
cardId: c.cardID,
|
|
4798
|
-
courseId: c.courseID,
|
|
4799
|
-
score: 1,
|
|
4800
|
-
provenance: [
|
|
4801
|
-
{
|
|
4802
|
-
strategy: "tagFilter",
|
|
4803
|
-
strategyName: "Tag Filter",
|
|
4804
|
-
strategyId: "TAG_FILTER",
|
|
4805
|
-
action: "generated",
|
|
4806
|
-
score: 1,
|
|
4807
|
-
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
4808
|
-
}
|
|
4809
|
-
]
|
|
4810
|
-
}))
|
|
4811
|
-
];
|
|
4812
|
-
return weighted.slice(0, limit);
|
|
3669
|
+
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
4813
3670
|
}
|
|
4814
3671
|
/**
|
|
4815
3672
|
* Clears the cached resolved card IDs.
|
|
@@ -4843,19 +3700,19 @@ async function getStudySource(source, user) {
|
|
|
4843
3700
|
if (source.type === "classroom") {
|
|
4844
3701
|
return await StudentClassroomDB.factory(source.id, user);
|
|
4845
3702
|
} else {
|
|
4846
|
-
if ((0,
|
|
3703
|
+
if ((0, import_common13.hasActiveFilter)(source.tagFilter)) {
|
|
4847
3704
|
return new TagFilteredContentSource(source.id, source.tagFilter, user);
|
|
4848
3705
|
}
|
|
4849
3706
|
return getDataLayer().getCourseDB(source.id);
|
|
4850
3707
|
}
|
|
4851
3708
|
}
|
|
4852
|
-
var
|
|
3709
|
+
var import_common13;
|
|
4853
3710
|
var init_contentSource = __esm({
|
|
4854
3711
|
"src/core/interfaces/contentSource.ts"() {
|
|
4855
3712
|
"use strict";
|
|
4856
3713
|
init_factory();
|
|
4857
3714
|
init_classroomDB2();
|
|
4858
|
-
|
|
3715
|
+
import_common13 = require("@vue-skuilder/common");
|
|
4859
3716
|
init_TagFilteredContentSource();
|
|
4860
3717
|
}
|
|
4861
3718
|
});
|
|
@@ -4979,7 +3836,7 @@ elo: ${elo}`;
|
|
|
4979
3836
|
misc: {}
|
|
4980
3837
|
} : void 0
|
|
4981
3838
|
);
|
|
4982
|
-
if (result.status ===
|
|
3839
|
+
if (result.status === import_common14.Status.ok) {
|
|
4983
3840
|
return {
|
|
4984
3841
|
originalText,
|
|
4985
3842
|
status: "success",
|
|
@@ -5023,17 +3880,17 @@ function validateProcessorConfig(config) {
|
|
|
5023
3880
|
}
|
|
5024
3881
|
return { isValid: true };
|
|
5025
3882
|
}
|
|
5026
|
-
var
|
|
3883
|
+
var import_common14;
|
|
5027
3884
|
var init_cardProcessor = __esm({
|
|
5028
3885
|
"src/core/bulkImport/cardProcessor.ts"() {
|
|
5029
3886
|
"use strict";
|
|
5030
|
-
|
|
3887
|
+
import_common14 = require("@vue-skuilder/common");
|
|
5031
3888
|
init_logger();
|
|
5032
3889
|
}
|
|
5033
3890
|
});
|
|
5034
3891
|
|
|
5035
3892
|
// src/core/bulkImport/types.ts
|
|
5036
|
-
var
|
|
3893
|
+
var init_types = __esm({
|
|
5037
3894
|
"src/core/bulkImport/types.ts"() {
|
|
5038
3895
|
"use strict";
|
|
5039
3896
|
}
|
|
@@ -5044,7 +3901,7 @@ var init_bulkImport = __esm({
|
|
|
5044
3901
|
"src/core/bulkImport/index.ts"() {
|
|
5045
3902
|
"use strict";
|
|
5046
3903
|
init_cardProcessor();
|
|
5047
|
-
|
|
3904
|
+
init_types();
|
|
5048
3905
|
}
|
|
5049
3906
|
});
|
|
5050
3907
|
|