@vue-skuilder/db 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{contentSource-BP9hznNV.d.ts → contentSource-BotbOOfX.d.ts} +227 -3
- package/dist/{contentSource-DsJadoBU.d.cts → contentSource-C90LH-OH.d.cts} +227 -3
- package/dist/core/index.d.cts +220 -6
- package/dist/core/index.d.ts +220 -6
- package/dist/core/index.js +2052 -559
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2035 -555
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-DGKp4zFB.d.cts} +1 -1
- package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-SBpz9jQf.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +11 -3
- package/dist/impl/couch/index.d.ts +11 -3
- package/dist/impl/couch/index.js +1811 -574
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +1792 -550
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +4 -4
- package/dist/impl/static/index.d.ts +4 -4
- package/dist/impl/static/index.js +1797 -560
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1789 -547
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Dj0SEgk3.d.ts → index-BWvO-_rJ.d.ts} +1 -1
- package/dist/{index-B_j6u5E4.d.cts → index-Ba7hYbHj.d.cts} +1 -1
- package/dist/index.d.cts +150 -12
- package/dist/index.d.ts +150 -12
- package/dist/index.js +2658 -791
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2584 -747
- package/dist/index.mjs.map +1 -1
- package/dist/{types-DQaXnuoc.d.ts → types-CJrLM1Ew.d.ts} +1 -1
- package/dist/{types-Bn0itutr.d.cts → types-W8n-B6HG.d.cts} +1 -1
- package/dist/{types-legacy-DDY4N-Uq.d.cts → types-legacy-JXDxinpU.d.cts} +5 -1
- package/dist/{types-legacy-DDY4N-Uq.d.ts → types-legacy-JXDxinpU.d.ts} +5 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/brainstorm-navigation-paradigm.md +40 -34
- package/docs/future-orchestration-vision.md +216 -0
- package/docs/navigators-architecture.md +188 -5
- package/docs/todo-strategy-authoring.md +8 -6
- package/package.json +3 -3
- package/src/core/index.ts +2 -0
- package/src/core/interfaces/contentSource.ts +7 -0
- package/src/core/interfaces/userDB.ts +6 -0
- package/src/core/navigators/Pipeline.ts +46 -0
- package/src/core/navigators/PipelineAssembler.ts +14 -1
- package/src/core/navigators/filters/WeightedFilter.ts +141 -0
- package/src/core/navigators/filters/types.ts +4 -0
- package/src/core/navigators/generators/CompositeGenerator.ts +61 -19
- package/src/core/navigators/generators/types.ts +4 -0
- package/src/core/navigators/index.ts +194 -13
- package/src/core/orchestration/gradient.ts +133 -0
- package/src/core/orchestration/index.ts +210 -0
- package/src/core/orchestration/learning.ts +250 -0
- package/src/core/orchestration/recording.ts +92 -0
- package/src/core/orchestration/signal.ts +67 -0
- package/src/core/types/contentNavigationStrategy.ts +38 -0
- package/src/core/types/learningState.ts +77 -0
- package/src/core/types/types-legacy.ts +4 -0
- package/src/core/types/userOutcome.ts +51 -0
- package/src/courseConfigRegistration.ts +546 -0
- package/src/factory.ts +6 -0
- package/src/impl/common/BaseUserDB.ts +16 -0
- package/src/index.ts +2 -0
- package/src/study/SessionController.ts +64 -1
- package/tests/core/navigators/Pipeline.test.ts +2 -0
- package/docs/todo-evolutionary-orchestration.md +0 -310
package/dist/impl/couch/index.js
CHANGED
|
@@ -5,6 +5,11 @@ 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
|
+
};
|
|
8
13
|
var __esm = (fn, res) => function __init() {
|
|
9
14
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
15
|
};
|
|
@@ -279,7 +284,9 @@ var init_types_legacy = __esm({
|
|
|
279
284
|
["VIEW" /* VIEW */]: "VIEW",
|
|
280
285
|
["PEDAGOGY" /* PEDAGOGY */]: "PEDAGOGY",
|
|
281
286
|
["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY",
|
|
282
|
-
["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE"
|
|
287
|
+
["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE",
|
|
288
|
+
["USER_OUTCOME" /* USER_OUTCOME */]: "USER_OUTCOME",
|
|
289
|
+
["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]: "STRATEGY_LEARNING_STATE"
|
|
283
290
|
};
|
|
284
291
|
}
|
|
285
292
|
});
|
|
@@ -620,175 +627,1384 @@ var init_courseLookupDB = __esm({
|
|
|
620
627
|
}
|
|
621
628
|
});
|
|
622
629
|
|
|
623
|
-
// src/core/navigators/
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
var
|
|
631
|
-
|
|
632
|
-
"src/core/navigators/index.ts"() {
|
|
630
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
631
|
+
var CompositeGenerator_exports = {};
|
|
632
|
+
__export(CompositeGenerator_exports, {
|
|
633
|
+
AggregationMode: () => AggregationMode,
|
|
634
|
+
default: () => CompositeGenerator
|
|
635
|
+
});
|
|
636
|
+
var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
637
|
+
var init_CompositeGenerator = __esm({
|
|
638
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
633
639
|
"use strict";
|
|
640
|
+
init_navigators();
|
|
634
641
|
init_logger();
|
|
635
|
-
|
|
636
|
-
["
|
|
637
|
-
["
|
|
638
|
-
["
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
* Call this from subclass constructors to initialize common fields.
|
|
655
|
-
*
|
|
656
|
-
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
657
|
-
* user/course fields directly if needed.
|
|
658
|
-
*/
|
|
659
|
-
constructor(user, course, strategyData) {
|
|
660
|
-
this.user = user;
|
|
661
|
-
this.course = course;
|
|
662
|
-
if (strategyData) {
|
|
663
|
-
this.strategyName = strategyData.name;
|
|
664
|
-
this.strategyId = strategyData._id;
|
|
642
|
+
AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
|
|
643
|
+
AggregationMode2["MAX"] = "max";
|
|
644
|
+
AggregationMode2["AVERAGE"] = "average";
|
|
645
|
+
AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
|
|
646
|
+
return AggregationMode2;
|
|
647
|
+
})(AggregationMode || {});
|
|
648
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
649
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
650
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
651
|
+
/** Human-readable name for CardGenerator interface */
|
|
652
|
+
name = "Composite Generator";
|
|
653
|
+
generators;
|
|
654
|
+
aggregationMode;
|
|
655
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
656
|
+
super();
|
|
657
|
+
this.generators = generators;
|
|
658
|
+
this.aggregationMode = aggregationMode;
|
|
659
|
+
if (generators.length === 0) {
|
|
660
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
665
661
|
}
|
|
662
|
+
logger.debug(
|
|
663
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
664
|
+
);
|
|
666
665
|
}
|
|
667
|
-
// ============================================================================
|
|
668
|
-
// STRATEGY STATE HELPERS
|
|
669
|
-
// ============================================================================
|
|
670
|
-
//
|
|
671
|
-
// These methods allow strategies to persist their own state (user preferences,
|
|
672
|
-
// learned patterns, temporal tracking) in the user database.
|
|
673
|
-
//
|
|
674
|
-
// ============================================================================
|
|
675
666
|
/**
|
|
676
|
-
*
|
|
667
|
+
* Creates a CompositeGenerator from strategy data.
|
|
677
668
|
*
|
|
678
|
-
*
|
|
679
|
-
* Override in subclasses if multiple instances of the same strategy type
|
|
680
|
-
* need separate state storage.
|
|
669
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
681
670
|
*/
|
|
682
|
-
|
|
683
|
-
|
|
671
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
672
|
+
const generators = await Promise.all(
|
|
673
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
674
|
+
);
|
|
675
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
684
676
|
}
|
|
685
677
|
/**
|
|
686
|
-
* Get
|
|
678
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
687
679
|
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
680
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
681
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
682
|
+
*
|
|
683
|
+
* This method supports both the legacy signature (limit only) and the
|
|
684
|
+
* CardGenerator interface signature (limit, context).
|
|
685
|
+
*
|
|
686
|
+
* @param limit - Maximum number of cards to return
|
|
687
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
690
688
|
*/
|
|
691
|
-
async
|
|
692
|
-
if (!
|
|
689
|
+
async getWeightedCards(limit, context) {
|
|
690
|
+
if (!context) {
|
|
693
691
|
throw new Error(
|
|
694
|
-
|
|
692
|
+
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
695
693
|
);
|
|
696
694
|
}
|
|
697
|
-
|
|
695
|
+
const results = await Promise.all(
|
|
696
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
697
|
+
);
|
|
698
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
699
|
+
results.forEach((cards, index) => {
|
|
700
|
+
const gen = this.generators[index];
|
|
701
|
+
let weight = gen.learnable?.weight ?? 1;
|
|
702
|
+
let deviation;
|
|
703
|
+
if (gen.learnable && !gen.staticWeight && context.orchestration) {
|
|
704
|
+
const strategyId = gen.strategyId;
|
|
705
|
+
if (strategyId) {
|
|
706
|
+
weight = context.orchestration.getEffectiveWeight(strategyId, gen.learnable);
|
|
707
|
+
deviation = context.orchestration.getDeviation(strategyId);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
for (const card of cards) {
|
|
711
|
+
if (card.provenance.length > 0) {
|
|
712
|
+
card.provenance[0].effectiveWeight = weight;
|
|
713
|
+
card.provenance[0].deviation = deviation;
|
|
714
|
+
}
|
|
715
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
716
|
+
existing.push({ card, weight });
|
|
717
|
+
byCardId.set(card.cardId, existing);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
const merged = [];
|
|
721
|
+
for (const [, items] of byCardId) {
|
|
722
|
+
const cards = items.map((i) => i.card);
|
|
723
|
+
const aggregatedScore = this.aggregateScores(items);
|
|
724
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
725
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
726
|
+
const initialScore = cards[0].score;
|
|
727
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
728
|
+
const reason = this.buildAggregationReason(items, finalScore);
|
|
729
|
+
merged.push({
|
|
730
|
+
...cards[0],
|
|
731
|
+
score: finalScore,
|
|
732
|
+
provenance: [
|
|
733
|
+
...mergedProvenance,
|
|
734
|
+
{
|
|
735
|
+
strategy: "composite",
|
|
736
|
+
strategyName: "Composite Generator",
|
|
737
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
738
|
+
action,
|
|
739
|
+
score: finalScore,
|
|
740
|
+
reason
|
|
741
|
+
}
|
|
742
|
+
]
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
698
746
|
}
|
|
699
747
|
/**
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
* @param data - The strategy's data payload to store
|
|
703
|
-
* @throws Error if user or course is not initialized
|
|
748
|
+
* Build human-readable reason for score aggregation.
|
|
704
749
|
*/
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
750
|
+
buildAggregationReason(items, finalScore) {
|
|
751
|
+
const cards = items.map((i) => i.card);
|
|
752
|
+
const count = cards.length;
|
|
753
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
754
|
+
if (count === 1) {
|
|
755
|
+
const weightMsg = Math.abs(items[0].weight - 1) > 1e-3 ? ` (w=${items[0].weight.toFixed(2)})` : "";
|
|
756
|
+
return `Single generator, score ${finalScore.toFixed(2)}${weightMsg}`;
|
|
757
|
+
}
|
|
758
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
759
|
+
switch (this.aggregationMode) {
|
|
760
|
+
case "max" /* MAX */:
|
|
761
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
762
|
+
case "average" /* AVERAGE */:
|
|
763
|
+
return `Weighted Avg of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
764
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
765
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
766
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
767
|
+
const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
768
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
769
|
+
return `Frequency boost from ${count} generators (${strategies}): w-avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
770
|
+
}
|
|
771
|
+
default:
|
|
772
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
710
773
|
}
|
|
711
|
-
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
712
774
|
}
|
|
713
775
|
/**
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
* @param user - User interface
|
|
717
|
-
* @param course - Course interface
|
|
718
|
-
* @param strategyData - Strategy configuration document
|
|
719
|
-
* @returns the runtime object used to steer a study session.
|
|
776
|
+
* Aggregate scores from multiple generators for the same card.
|
|
720
777
|
*/
|
|
721
|
-
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
NavigatorImpl = module2.default;
|
|
732
|
-
break;
|
|
733
|
-
} catch (e) {
|
|
734
|
-
logger.debug(`Failed to load extension from ${loadFrom}:`, e);
|
|
735
|
-
}
|
|
778
|
+
aggregateScores(items) {
|
|
779
|
+
const scores = items.map((i) => i.card.score);
|
|
780
|
+
switch (this.aggregationMode) {
|
|
781
|
+
case "max" /* MAX */:
|
|
782
|
+
return Math.max(...scores);
|
|
783
|
+
case "average" /* AVERAGE */: {
|
|
784
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
785
|
+
if (totalWeight === 0) return 0;
|
|
786
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
787
|
+
return weightedSum / totalWeight;
|
|
736
788
|
}
|
|
789
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
790
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
791
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
792
|
+
const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
793
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (items.length - 1);
|
|
794
|
+
return avg * frequencyBoost;
|
|
795
|
+
}
|
|
796
|
+
default:
|
|
797
|
+
return scores[0];
|
|
737
798
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// src/core/navigators/generators/elo.ts
|
|
805
|
+
var elo_exports = {};
|
|
806
|
+
__export(elo_exports, {
|
|
807
|
+
default: () => ELONavigator
|
|
808
|
+
});
|
|
809
|
+
var import_common5, ELONavigator;
|
|
810
|
+
var init_elo = __esm({
|
|
811
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
812
|
+
"use strict";
|
|
813
|
+
init_navigators();
|
|
814
|
+
import_common5 = require("@vue-skuilder/common");
|
|
815
|
+
ELONavigator = class extends ContentNavigator {
|
|
816
|
+
/** Human-readable name for CardGenerator interface */
|
|
817
|
+
name;
|
|
818
|
+
constructor(user, course, strategyData) {
|
|
819
|
+
super(user, course, strategyData);
|
|
820
|
+
this.name = strategyData?.name || "ELO";
|
|
742
821
|
}
|
|
743
822
|
/**
|
|
744
|
-
* Get cards with suitability scores
|
|
745
|
-
*
|
|
746
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
747
|
-
*
|
|
748
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
749
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
750
|
-
* documenting how strategies contributed to the final score.
|
|
823
|
+
* Get new cards with suitability scores based on ELO distance.
|
|
751
824
|
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
* not provide a default implementation.
|
|
825
|
+
* Cards closer to user's ELO get higher scores.
|
|
826
|
+
* Score formula: max(0, 1 - distance / 500)
|
|
755
827
|
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
759
|
-
* initial provenance entry with action='generated'.
|
|
828
|
+
* NOTE: This generator only handles NEW cards. Reviews are handled by
|
|
829
|
+
* SRSNavigator. Use CompositeGenerator to combine both.
|
|
760
830
|
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
831
|
+
* This method supports both the legacy signature (limit only) and the
|
|
832
|
+
* CardGenerator interface signature (limit, context).
|
|
764
833
|
*
|
|
765
|
-
* @param limit - Maximum cards to return
|
|
766
|
-
* @
|
|
834
|
+
* @param limit - Maximum number of cards to return
|
|
835
|
+
* @param context - Optional GeneratorContext (used when called via Pipeline)
|
|
767
836
|
*/
|
|
768
|
-
async getWeightedCards(
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
837
|
+
async getWeightedCards(limit, context) {
|
|
838
|
+
let userGlobalElo;
|
|
839
|
+
if (context?.userElo !== void 0) {
|
|
840
|
+
userGlobalElo = context.userElo;
|
|
841
|
+
} else {
|
|
842
|
+
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
843
|
+
const userElo = (0, import_common5.toCourseElo)(courseReg.elo);
|
|
844
|
+
userGlobalElo = userElo.global.score;
|
|
845
|
+
}
|
|
846
|
+
const activeCards = await this.user.getActiveCards();
|
|
847
|
+
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
848
|
+
{ limit, elo: "user" },
|
|
849
|
+
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
850
|
+
)).map((c) => ({ ...c, status: "new" }));
|
|
851
|
+
const cardIds = newCards.map((c) => c.cardID);
|
|
852
|
+
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
853
|
+
const scored = newCards.map((c, i) => {
|
|
854
|
+
const cardElo = cardEloData[i]?.global?.score ?? 1e3;
|
|
855
|
+
const distance = Math.abs(cardElo - userGlobalElo);
|
|
856
|
+
const score = Math.max(0, 1 - distance / 500);
|
|
857
|
+
return {
|
|
858
|
+
cardId: c.cardID,
|
|
859
|
+
courseId: c.courseID,
|
|
860
|
+
score,
|
|
861
|
+
provenance: [
|
|
862
|
+
{
|
|
863
|
+
strategy: "elo",
|
|
864
|
+
strategyName: this.strategyName || this.name,
|
|
865
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
|
|
866
|
+
action: "generated",
|
|
867
|
+
score,
|
|
868
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`
|
|
869
|
+
}
|
|
870
|
+
]
|
|
871
|
+
};
|
|
872
|
+
});
|
|
873
|
+
scored.sort((a, b) => b.score - a.score);
|
|
874
|
+
return scored.slice(0, limit);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// src/core/navigators/generators/index.ts
|
|
881
|
+
var generators_exports = {};
|
|
882
|
+
var init_generators = __esm({
|
|
883
|
+
"src/core/navigators/generators/index.ts"() {
|
|
884
|
+
"use strict";
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// src/core/navigators/generators/srs.ts
|
|
889
|
+
var srs_exports = {};
|
|
890
|
+
__export(srs_exports, {
|
|
891
|
+
default: () => SRSNavigator
|
|
892
|
+
});
|
|
893
|
+
var import_moment, SRSNavigator;
|
|
894
|
+
var init_srs = __esm({
|
|
895
|
+
"src/core/navigators/generators/srs.ts"() {
|
|
896
|
+
"use strict";
|
|
897
|
+
import_moment = __toESM(require("moment"), 1);
|
|
898
|
+
init_navigators();
|
|
899
|
+
init_logger();
|
|
900
|
+
SRSNavigator = class extends ContentNavigator {
|
|
901
|
+
/** Human-readable name for CardGenerator interface */
|
|
902
|
+
name;
|
|
903
|
+
constructor(user, course, strategyData) {
|
|
904
|
+
super(user, course, strategyData);
|
|
905
|
+
this.name = strategyData?.name || "SRS";
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Get review cards scored by urgency.
|
|
909
|
+
*
|
|
910
|
+
* Score formula combines:
|
|
911
|
+
* - Relative overdueness: hoursOverdue / intervalHours
|
|
912
|
+
* - Interval recency: exponential decay favoring shorter intervals
|
|
913
|
+
*
|
|
914
|
+
* Cards not yet due are excluded (not scored as 0).
|
|
915
|
+
*
|
|
916
|
+
* This method supports both the legacy signature (limit only) and the
|
|
917
|
+
* CardGenerator interface signature (limit, context).
|
|
918
|
+
*
|
|
919
|
+
* @param limit - Maximum number of cards to return
|
|
920
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
921
|
+
*/
|
|
922
|
+
async getWeightedCards(limit, _context) {
|
|
923
|
+
if (!this.user || !this.course) {
|
|
924
|
+
throw new Error("SRSNavigator requires user and course to be set");
|
|
925
|
+
}
|
|
926
|
+
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
927
|
+
const now = import_moment.default.utc();
|
|
928
|
+
const dueReviews = reviews.filter((r) => now.isAfter(import_moment.default.utc(r.reviewTime)));
|
|
929
|
+
const scored = dueReviews.map((review) => {
|
|
930
|
+
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
931
|
+
return {
|
|
932
|
+
cardId: review.cardId,
|
|
933
|
+
courseId: review.courseId,
|
|
934
|
+
score,
|
|
935
|
+
reviewID: review._id,
|
|
936
|
+
provenance: [
|
|
937
|
+
{
|
|
938
|
+
strategy: "srs",
|
|
939
|
+
strategyName: this.strategyName || this.name,
|
|
940
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
|
|
941
|
+
action: "generated",
|
|
942
|
+
score,
|
|
943
|
+
reason
|
|
944
|
+
}
|
|
945
|
+
]
|
|
946
|
+
};
|
|
947
|
+
});
|
|
948
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
949
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Compute urgency score for a review card.
|
|
953
|
+
*
|
|
954
|
+
* Two factors:
|
|
955
|
+
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
956
|
+
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
957
|
+
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
958
|
+
*
|
|
959
|
+
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
960
|
+
* - 24h interval → ~1.0 (very recent learning)
|
|
961
|
+
* - 30 days (720h) → ~0.56
|
|
962
|
+
* - 180 days → ~0.30
|
|
963
|
+
*
|
|
964
|
+
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
965
|
+
* Result range: approximately 0.5 to 0.95
|
|
966
|
+
*/
|
|
967
|
+
computeUrgencyScore(review, now) {
|
|
968
|
+
const scheduledAt = import_moment.default.utc(review.scheduledAt);
|
|
969
|
+
const due = import_moment.default.utc(review.reviewTime);
|
|
970
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
971
|
+
const hoursOverdue = now.diff(due, "hours");
|
|
972
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
973
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
974
|
+
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
975
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
976
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
977
|
+
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
978
|
+
return { score, reason };
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// src/core/navigators/generators/types.ts
|
|
985
|
+
var types_exports = {};
|
|
986
|
+
var init_types = __esm({
|
|
987
|
+
"src/core/navigators/generators/types.ts"() {
|
|
988
|
+
"use strict";
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
// import("./generators/**/*") in src/core/navigators/index.ts
|
|
993
|
+
var globImport_generators;
|
|
994
|
+
var init_ = __esm({
|
|
995
|
+
'import("./generators/**/*") in src/core/navigators/index.ts'() {
|
|
996
|
+
globImport_generators = __glob({
|
|
997
|
+
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
998
|
+
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
999
|
+
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1000
|
+
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1001
|
+
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// src/core/types/contentNavigationStrategy.ts
|
|
1007
|
+
var DEFAULT_LEARNABLE_WEIGHT;
|
|
1008
|
+
var init_contentNavigationStrategy = __esm({
|
|
1009
|
+
"src/core/types/contentNavigationStrategy.ts"() {
|
|
1010
|
+
"use strict";
|
|
1011
|
+
DEFAULT_LEARNABLE_WEIGHT = {
|
|
1012
|
+
weight: 1,
|
|
1013
|
+
confidence: 0.1,
|
|
1014
|
+
// Low confidence initially = wide exploration
|
|
1015
|
+
sampleSize: 0
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// src/core/navigators/filters/WeightedFilter.ts
|
|
1021
|
+
var WeightedFilter_exports = {};
|
|
1022
|
+
__export(WeightedFilter_exports, {
|
|
1023
|
+
WeightedFilter: () => WeightedFilter
|
|
1024
|
+
});
|
|
1025
|
+
var WeightedFilter;
|
|
1026
|
+
var init_WeightedFilter = __esm({
|
|
1027
|
+
"src/core/navigators/filters/WeightedFilter.ts"() {
|
|
1028
|
+
"use strict";
|
|
1029
|
+
init_contentNavigationStrategy();
|
|
1030
|
+
WeightedFilter = class {
|
|
1031
|
+
name;
|
|
1032
|
+
inner;
|
|
1033
|
+
learnable;
|
|
1034
|
+
staticWeight;
|
|
1035
|
+
strategyId;
|
|
1036
|
+
constructor(inner, learnable = DEFAULT_LEARNABLE_WEIGHT, staticWeight = false, strategyId) {
|
|
1037
|
+
this.inner = inner;
|
|
1038
|
+
this.name = inner.name;
|
|
1039
|
+
this.learnable = learnable;
|
|
1040
|
+
this.staticWeight = staticWeight;
|
|
1041
|
+
this.strategyId = strategyId;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Apply the inner filter, then scale its effect by the configured weight.
|
|
1045
|
+
*/
|
|
1046
|
+
async transform(cards, context) {
|
|
1047
|
+
let effectiveWeight = this.learnable.weight;
|
|
1048
|
+
let deviation;
|
|
1049
|
+
if (!this.staticWeight && context.orchestration) {
|
|
1050
|
+
const strategyId = this.strategyId || this.inner.strategyId || this.name;
|
|
1051
|
+
effectiveWeight = context.orchestration.getEffectiveWeight(strategyId, this.learnable);
|
|
1052
|
+
deviation = context.orchestration.getDeviation(strategyId);
|
|
1053
|
+
}
|
|
1054
|
+
if (Math.abs(effectiveWeight - 1) < 1e-3) {
|
|
1055
|
+
return this.inner.transform(cards, context);
|
|
1056
|
+
}
|
|
1057
|
+
const originalScores = /* @__PURE__ */ new Map();
|
|
1058
|
+
for (const card of cards) {
|
|
1059
|
+
originalScores.set(card.cardId, card.score);
|
|
1060
|
+
}
|
|
1061
|
+
const transformedCards = await this.inner.transform(cards, context);
|
|
1062
|
+
return transformedCards.map((card) => {
|
|
1063
|
+
const originalScore = originalScores.get(card.cardId);
|
|
1064
|
+
if (originalScore === void 0 || originalScore === 0 || card.score === 0) {
|
|
1065
|
+
return card;
|
|
1066
|
+
}
|
|
1067
|
+
const rawEffect = card.score / originalScore;
|
|
1068
|
+
if (Math.abs(rawEffect - 1) < 1e-4) {
|
|
1069
|
+
return card;
|
|
1070
|
+
}
|
|
1071
|
+
const weightedEffect = Math.pow(rawEffect, effectiveWeight);
|
|
1072
|
+
const newScore = originalScore * weightedEffect;
|
|
1073
|
+
const lastProvIndex = card.provenance.length - 1;
|
|
1074
|
+
const lastProv = card.provenance[lastProvIndex];
|
|
1075
|
+
if (lastProv) {
|
|
1076
|
+
const updatedProvenance = [...card.provenance];
|
|
1077
|
+
updatedProvenance[lastProvIndex] = {
|
|
1078
|
+
...lastProv,
|
|
1079
|
+
score: newScore,
|
|
1080
|
+
effectiveWeight,
|
|
1081
|
+
deviation
|
|
1082
|
+
// We can optionally append to the reason, but the structured field is key
|
|
1083
|
+
};
|
|
1084
|
+
return {
|
|
1085
|
+
...card,
|
|
1086
|
+
score: newScore,
|
|
1087
|
+
provenance: updatedProvenance
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
return {
|
|
1091
|
+
...card,
|
|
1092
|
+
score: newScore
|
|
1093
|
+
};
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1101
|
+
var eloDistance_exports = {};
|
|
1102
|
+
__export(eloDistance_exports, {
|
|
1103
|
+
DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
|
|
1104
|
+
DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
|
|
1105
|
+
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1106
|
+
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1107
|
+
});
|
|
1108
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1109
|
+
const normalizedDistance = distance / halfLife;
|
|
1110
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1111
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1112
|
+
}
|
|
1113
|
+
function createEloDistanceFilter(config) {
|
|
1114
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1115
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1116
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1117
|
+
return {
|
|
1118
|
+
name: "ELO Distance Filter",
|
|
1119
|
+
async transform(cards, context) {
|
|
1120
|
+
const { course, userElo } = context;
|
|
1121
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1122
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1123
|
+
return cards.map((card, i) => {
|
|
1124
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1125
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1126
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1127
|
+
const newScore = card.score * multiplier;
|
|
1128
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1129
|
+
return {
|
|
1130
|
+
...card,
|
|
1131
|
+
score: newScore,
|
|
1132
|
+
provenance: [
|
|
1133
|
+
...card.provenance,
|
|
1134
|
+
{
|
|
1135
|
+
strategy: "eloDistance",
|
|
1136
|
+
strategyName: "ELO Distance Filter",
|
|
1137
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1138
|
+
action,
|
|
1139
|
+
score: newScore,
|
|
1140
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1141
|
+
}
|
|
1142
|
+
]
|
|
1143
|
+
};
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1149
|
+
var init_eloDistance = __esm({
|
|
1150
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1151
|
+
"use strict";
|
|
1152
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1153
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1154
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// src/core/navigators/filters/hierarchyDefinition.ts
|
|
1159
|
+
var hierarchyDefinition_exports = {};
|
|
1160
|
+
__export(hierarchyDefinition_exports, {
|
|
1161
|
+
default: () => HierarchyDefinitionNavigator
|
|
1162
|
+
});
|
|
1163
|
+
var import_common6, DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1164
|
+
var init_hierarchyDefinition = __esm({
|
|
1165
|
+
"src/core/navigators/filters/hierarchyDefinition.ts"() {
|
|
1166
|
+
"use strict";
|
|
1167
|
+
init_navigators();
|
|
1168
|
+
import_common6 = require("@vue-skuilder/common");
|
|
1169
|
+
DEFAULT_MIN_COUNT = 3;
|
|
1170
|
+
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1171
|
+
config;
|
|
1172
|
+
/** Human-readable name for CardFilter interface */
|
|
1173
|
+
name;
|
|
1174
|
+
constructor(user, course, strategyData) {
|
|
1175
|
+
super(user, course, strategyData);
|
|
1176
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1177
|
+
this.name = strategyData.name || "Hierarchy Definition";
|
|
1178
|
+
}
|
|
1179
|
+
parseConfig(serializedData) {
|
|
1180
|
+
try {
|
|
1181
|
+
const parsed = JSON.parse(serializedData);
|
|
1182
|
+
return {
|
|
1183
|
+
prerequisites: parsed.prerequisites || {}
|
|
1184
|
+
};
|
|
1185
|
+
} catch {
|
|
1186
|
+
return {
|
|
1187
|
+
prerequisites: {}
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Check if a specific prerequisite is satisfied
|
|
1193
|
+
*/
|
|
1194
|
+
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1195
|
+
if (!userTagElo) return false;
|
|
1196
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1197
|
+
if (userTagElo.count < minCount) return false;
|
|
1198
|
+
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1199
|
+
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1200
|
+
} else {
|
|
1201
|
+
return userTagElo.score >= userGlobalElo;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Get the set of tags the user has mastered.
|
|
1206
|
+
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1207
|
+
*/
|
|
1208
|
+
async getMasteredTags(context) {
|
|
1209
|
+
const mastered = /* @__PURE__ */ new Set();
|
|
1210
|
+
try {
|
|
1211
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1212
|
+
const userElo = (0, import_common6.toCourseElo)(courseReg.elo);
|
|
1213
|
+
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1214
|
+
for (const prereq of prereqs) {
|
|
1215
|
+
const tagElo = userElo.tags[prereq.tag];
|
|
1216
|
+
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1217
|
+
mastered.add(prereq.tag);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
} catch {
|
|
1222
|
+
}
|
|
1223
|
+
return mastered;
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Get the set of tags that are unlocked (prerequisites met)
|
|
1227
|
+
*/
|
|
1228
|
+
getUnlockedTags(masteredTags) {
|
|
1229
|
+
const unlocked = /* @__PURE__ */ new Set();
|
|
1230
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1231
|
+
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1232
|
+
if (allPrereqsMet) {
|
|
1233
|
+
unlocked.add(tagId);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return unlocked;
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Check if a tag has prerequisites defined in config
|
|
1240
|
+
*/
|
|
1241
|
+
hasPrerequisites(tagId) {
|
|
1242
|
+
return tagId in this.config.prerequisites;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Check if a card is unlocked and generate reason.
|
|
1246
|
+
*/
|
|
1247
|
+
async checkCardUnlock(card, _course, unlockedTags, masteredTags) {
|
|
1248
|
+
try {
|
|
1249
|
+
const cardTags = card.tags ?? [];
|
|
1250
|
+
const lockedTags = cardTags.filter(
|
|
1251
|
+
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1252
|
+
);
|
|
1253
|
+
if (lockedTags.length === 0) {
|
|
1254
|
+
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1255
|
+
return {
|
|
1256
|
+
isUnlocked: true,
|
|
1257
|
+
reason: `Prerequisites met, tags: ${tagList}`
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1261
|
+
const prereqs = this.config.prerequisites[tag] || [];
|
|
1262
|
+
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1263
|
+
});
|
|
1264
|
+
return {
|
|
1265
|
+
isUnlocked: false,
|
|
1266
|
+
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1267
|
+
};
|
|
1268
|
+
} catch {
|
|
1269
|
+
return {
|
|
1270
|
+
isUnlocked: true,
|
|
1271
|
+
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* CardFilter.transform implementation.
|
|
1277
|
+
*
|
|
1278
|
+
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1279
|
+
*/
|
|
1280
|
+
async transform(cards, context) {
|
|
1281
|
+
const masteredTags = await this.getMasteredTags(context);
|
|
1282
|
+
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1283
|
+
const gated = [];
|
|
1284
|
+
for (const card of cards) {
|
|
1285
|
+
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1286
|
+
card,
|
|
1287
|
+
context.course,
|
|
1288
|
+
unlockedTags,
|
|
1289
|
+
masteredTags
|
|
1290
|
+
);
|
|
1291
|
+
const finalScore = isUnlocked ? card.score : 0;
|
|
1292
|
+
const action = isUnlocked ? "passed" : "penalized";
|
|
1293
|
+
gated.push({
|
|
1294
|
+
...card,
|
|
1295
|
+
score: finalScore,
|
|
1296
|
+
provenance: [
|
|
1297
|
+
...card.provenance,
|
|
1298
|
+
{
|
|
1299
|
+
strategy: "hierarchyDefinition",
|
|
1300
|
+
strategyName: this.strategyName || this.name,
|
|
1301
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1302
|
+
action,
|
|
1303
|
+
score: finalScore,
|
|
1304
|
+
reason
|
|
1305
|
+
}
|
|
1306
|
+
]
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
return gated;
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1313
|
+
*
|
|
1314
|
+
* Use transform() via Pipeline instead.
|
|
1315
|
+
*/
|
|
1316
|
+
async getWeightedCards(_limit) {
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
// src/core/navigators/filters/userTagPreference.ts
|
|
1326
|
+
var userTagPreference_exports = {};
|
|
1327
|
+
__export(userTagPreference_exports, {
|
|
1328
|
+
default: () => UserTagPreferenceFilter
|
|
1329
|
+
});
|
|
1330
|
+
var UserTagPreferenceFilter;
|
|
1331
|
+
var init_userTagPreference = __esm({
|
|
1332
|
+
"src/core/navigators/filters/userTagPreference.ts"() {
|
|
1333
|
+
"use strict";
|
|
1334
|
+
init_navigators();
|
|
1335
|
+
UserTagPreferenceFilter = class extends ContentNavigator {
|
|
1336
|
+
_strategyData;
|
|
1337
|
+
/** Human-readable name for CardFilter interface */
|
|
1338
|
+
name;
|
|
1339
|
+
constructor(user, course, strategyData) {
|
|
1340
|
+
super(user, course, strategyData);
|
|
1341
|
+
this._strategyData = strategyData;
|
|
1342
|
+
this.name = strategyData.name || "User Tag Preferences";
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Compute multiplier for a card based on its tags and user preferences.
|
|
1346
|
+
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
1347
|
+
*/
|
|
1348
|
+
computeMultiplier(cardTags, boostMap) {
|
|
1349
|
+
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
|
|
1350
|
+
if (multipliers.length === 0) {
|
|
1351
|
+
return 1;
|
|
1352
|
+
}
|
|
1353
|
+
return Math.max(...multipliers);
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Build human-readable reason for the filter's decision.
|
|
1357
|
+
*/
|
|
1358
|
+
buildReason(cardTags, boostMap, multiplier) {
|
|
1359
|
+
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
1360
|
+
if (multiplier === 0) {
|
|
1361
|
+
return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
|
|
1362
|
+
}
|
|
1363
|
+
if (multiplier < 1) {
|
|
1364
|
+
return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1365
|
+
}
|
|
1366
|
+
if (multiplier > 1) {
|
|
1367
|
+
return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1368
|
+
}
|
|
1369
|
+
return "No matching user preferences";
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* CardFilter.transform implementation.
|
|
1373
|
+
*
|
|
1374
|
+
* Apply user tag preferences:
|
|
1375
|
+
* 1. Read preferences from strategy state
|
|
1376
|
+
* 2. If no preferences, pass through unchanged
|
|
1377
|
+
* 3. For each card:
|
|
1378
|
+
* - Look up tag in boost record
|
|
1379
|
+
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
1380
|
+
* - If multiple tags match: use max multiplier
|
|
1381
|
+
* - Append provenance with clear reason
|
|
1382
|
+
*/
|
|
1383
|
+
async transform(cards, _context) {
|
|
1384
|
+
const prefs = await this.getStrategyState();
|
|
1385
|
+
if (!prefs || Object.keys(prefs.boost).length === 0) {
|
|
1386
|
+
return cards.map((card) => ({
|
|
1387
|
+
...card,
|
|
1388
|
+
provenance: [
|
|
1389
|
+
...card.provenance,
|
|
1390
|
+
{
|
|
1391
|
+
strategy: "userTagPreference",
|
|
1392
|
+
strategyName: this.strategyName || this.name,
|
|
1393
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
1394
|
+
action: "passed",
|
|
1395
|
+
score: card.score,
|
|
1396
|
+
reason: "No user tag preferences configured"
|
|
1397
|
+
}
|
|
1398
|
+
]
|
|
1399
|
+
}));
|
|
1400
|
+
}
|
|
1401
|
+
const adjusted = await Promise.all(
|
|
1402
|
+
cards.map(async (card) => {
|
|
1403
|
+
const cardTags = card.tags ?? [];
|
|
1404
|
+
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
1405
|
+
const finalScore = Math.min(1, card.score * multiplier);
|
|
1406
|
+
let action;
|
|
1407
|
+
if (multiplier === 0 || multiplier < 1) {
|
|
1408
|
+
action = "penalized";
|
|
1409
|
+
} else if (multiplier > 1) {
|
|
1410
|
+
action = "boosted";
|
|
1411
|
+
} else {
|
|
1412
|
+
action = "passed";
|
|
1413
|
+
}
|
|
1414
|
+
return {
|
|
1415
|
+
...card,
|
|
1416
|
+
score: finalScore,
|
|
1417
|
+
provenance: [
|
|
1418
|
+
...card.provenance,
|
|
1419
|
+
{
|
|
1420
|
+
strategy: "userTagPreference",
|
|
1421
|
+
strategyName: this.strategyName || this.name,
|
|
1422
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
1423
|
+
action,
|
|
1424
|
+
score: finalScore,
|
|
1425
|
+
reason: this.buildReason(cardTags, prefs.boost, multiplier)
|
|
1426
|
+
}
|
|
1427
|
+
]
|
|
1428
|
+
};
|
|
1429
|
+
})
|
|
1430
|
+
);
|
|
1431
|
+
return adjusted;
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
1435
|
+
*/
|
|
1436
|
+
async getWeightedCards(_limit) {
|
|
1437
|
+
throw new Error(
|
|
1438
|
+
"UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
// src/core/navigators/filters/index.ts
|
|
1446
|
+
var filters_exports = {};
|
|
1447
|
+
__export(filters_exports, {
|
|
1448
|
+
UserTagPreferenceFilter: () => UserTagPreferenceFilter,
|
|
1449
|
+
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1450
|
+
});
|
|
1451
|
+
var init_filters = __esm({
|
|
1452
|
+
"src/core/navigators/filters/index.ts"() {
|
|
1453
|
+
"use strict";
|
|
1454
|
+
init_eloDistance();
|
|
1455
|
+
init_userTagPreference();
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
// src/core/navigators/filters/inferredPreferenceStub.ts
|
|
1460
|
+
var inferredPreferenceStub_exports = {};
|
|
1461
|
+
__export(inferredPreferenceStub_exports, {
|
|
1462
|
+
INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
|
|
1463
|
+
});
|
|
1464
|
+
var INFERRED_PREFERENCE_NAVIGATOR_STUB;
|
|
1465
|
+
var init_inferredPreferenceStub = __esm({
|
|
1466
|
+
"src/core/navigators/filters/inferredPreferenceStub.ts"() {
|
|
1467
|
+
"use strict";
|
|
1468
|
+
INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
// src/core/navigators/filters/interferenceMitigator.ts
|
|
1473
|
+
var interferenceMitigator_exports = {};
|
|
1474
|
+
__export(interferenceMitigator_exports, {
|
|
1475
|
+
default: () => InterferenceMitigatorNavigator
|
|
1476
|
+
});
|
|
1477
|
+
var import_common7, DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
1478
|
+
var init_interferenceMitigator = __esm({
|
|
1479
|
+
"src/core/navigators/filters/interferenceMitigator.ts"() {
|
|
1480
|
+
"use strict";
|
|
1481
|
+
init_navigators();
|
|
1482
|
+
import_common7 = require("@vue-skuilder/common");
|
|
1483
|
+
DEFAULT_MIN_COUNT2 = 10;
|
|
1484
|
+
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
1485
|
+
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
1486
|
+
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
1487
|
+
config;
|
|
1488
|
+
/** Human-readable name for CardFilter interface */
|
|
1489
|
+
name;
|
|
1490
|
+
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
1491
|
+
interferenceMap;
|
|
1492
|
+
constructor(user, course, strategyData) {
|
|
1493
|
+
super(user, course, strategyData);
|
|
1494
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1495
|
+
this.interferenceMap = this.buildInterferenceMap();
|
|
1496
|
+
this.name = strategyData.name || "Interference Mitigator";
|
|
1497
|
+
}
|
|
1498
|
+
parseConfig(serializedData) {
|
|
1499
|
+
try {
|
|
1500
|
+
const parsed = JSON.parse(serializedData);
|
|
1501
|
+
let sets = parsed.interferenceSets || [];
|
|
1502
|
+
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
1503
|
+
sets = sets.map((tags) => ({ tags }));
|
|
1504
|
+
}
|
|
1505
|
+
return {
|
|
1506
|
+
interferenceSets: sets,
|
|
1507
|
+
maturityThreshold: {
|
|
1508
|
+
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
1509
|
+
minElo: parsed.maturityThreshold?.minElo,
|
|
1510
|
+
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
1511
|
+
},
|
|
1512
|
+
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
1513
|
+
};
|
|
1514
|
+
} catch {
|
|
1515
|
+
return {
|
|
1516
|
+
interferenceSets: [],
|
|
1517
|
+
maturityThreshold: {
|
|
1518
|
+
minCount: DEFAULT_MIN_COUNT2,
|
|
1519
|
+
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
1520
|
+
},
|
|
1521
|
+
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Build a map from each tag to its interference partners with decay coefficients.
|
|
1527
|
+
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
1528
|
+
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
1529
|
+
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
1530
|
+
* - etc.
|
|
1531
|
+
*/
|
|
1532
|
+
buildInterferenceMap() {
|
|
1533
|
+
const map = /* @__PURE__ */ new Map();
|
|
1534
|
+
for (const group of this.config.interferenceSets) {
|
|
1535
|
+
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
1536
|
+
for (const tag of group.tags) {
|
|
1537
|
+
if (!map.has(tag)) {
|
|
1538
|
+
map.set(tag, []);
|
|
1539
|
+
}
|
|
1540
|
+
const partners = map.get(tag);
|
|
1541
|
+
for (const other of group.tags) {
|
|
1542
|
+
if (other !== tag) {
|
|
1543
|
+
const existing = partners.find((p) => p.partner === other);
|
|
1544
|
+
if (existing) {
|
|
1545
|
+
existing.decay = Math.max(existing.decay, decay);
|
|
1546
|
+
} else {
|
|
1547
|
+
partners.push({ partner: other, decay });
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return map;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Get the set of tags that are currently immature for this user.
|
|
1557
|
+
* A tag is immature if the user has interacted with it but hasn't
|
|
1558
|
+
* reached the maturity threshold.
|
|
1559
|
+
*/
|
|
1560
|
+
async getImmatureTags(context) {
|
|
1561
|
+
const immature = /* @__PURE__ */ new Set();
|
|
1562
|
+
try {
|
|
1563
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1564
|
+
const userElo = (0, import_common7.toCourseElo)(courseReg.elo);
|
|
1565
|
+
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1566
|
+
const minElo = this.config.maturityThreshold?.minElo;
|
|
1567
|
+
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
1568
|
+
const minCountForElapsed = minElapsedDays * 2;
|
|
1569
|
+
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
1570
|
+
if (tagElo.count === 0) continue;
|
|
1571
|
+
const belowCount = tagElo.count < minCount;
|
|
1572
|
+
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
1573
|
+
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
1574
|
+
if (belowCount || belowElo || belowElapsed) {
|
|
1575
|
+
immature.add(tagId);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
} catch {
|
|
1579
|
+
}
|
|
1580
|
+
return immature;
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
1584
|
+
* These are the tags we want to avoid introducing.
|
|
1585
|
+
*/
|
|
1586
|
+
getTagsToAvoid(immatureTags) {
|
|
1587
|
+
const avoid = /* @__PURE__ */ new Map();
|
|
1588
|
+
for (const immatureTag of immatureTags) {
|
|
1589
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
1590
|
+
if (partners) {
|
|
1591
|
+
for (const { partner, decay } of partners) {
|
|
1592
|
+
if (!immatureTags.has(partner)) {
|
|
1593
|
+
const existing = avoid.get(partner) ?? 0;
|
|
1594
|
+
avoid.set(partner, Math.max(existing, decay));
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
return avoid;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Compute interference score reduction for a card.
|
|
1603
|
+
* Returns: { multiplier, interfering tags, reason }
|
|
1604
|
+
*/
|
|
1605
|
+
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
1606
|
+
if (tagsToAvoid.size === 0) {
|
|
1607
|
+
return {
|
|
1608
|
+
multiplier: 1,
|
|
1609
|
+
interferingTags: [],
|
|
1610
|
+
reason: "No interference detected"
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
let multiplier = 1;
|
|
1614
|
+
const interferingTags = [];
|
|
1615
|
+
for (const tag of cardTags) {
|
|
1616
|
+
const decay = tagsToAvoid.get(tag);
|
|
1617
|
+
if (decay !== void 0) {
|
|
1618
|
+
interferingTags.push(tag);
|
|
1619
|
+
multiplier *= 1 - decay;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
if (interferingTags.length === 0) {
|
|
1623
|
+
return {
|
|
1624
|
+
multiplier: 1,
|
|
1625
|
+
interferingTags: [],
|
|
1626
|
+
reason: "No interference detected"
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
const causingTags = /* @__PURE__ */ new Set();
|
|
1630
|
+
for (const tag of interferingTags) {
|
|
1631
|
+
for (const immatureTag of immatureTags) {
|
|
1632
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
1633
|
+
if (partners?.some((p) => p.partner === tag)) {
|
|
1634
|
+
causingTags.add(immatureTag);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
1639
|
+
return { multiplier, interferingTags, reason };
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* CardFilter.transform implementation.
|
|
1643
|
+
*
|
|
1644
|
+
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
1645
|
+
* immature learnings get reduced scores.
|
|
1646
|
+
*/
|
|
1647
|
+
async transform(cards, context) {
|
|
1648
|
+
const immatureTags = await this.getImmatureTags(context);
|
|
1649
|
+
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
1650
|
+
const adjusted = [];
|
|
1651
|
+
for (const card of cards) {
|
|
1652
|
+
const cardTags = card.tags ?? [];
|
|
1653
|
+
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
1654
|
+
cardTags,
|
|
1655
|
+
tagsToAvoid,
|
|
1656
|
+
immatureTags
|
|
1657
|
+
);
|
|
1658
|
+
const finalScore = card.score * multiplier;
|
|
1659
|
+
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
1660
|
+
adjusted.push({
|
|
1661
|
+
...card,
|
|
1662
|
+
score: finalScore,
|
|
1663
|
+
provenance: [
|
|
1664
|
+
...card.provenance,
|
|
1665
|
+
{
|
|
1666
|
+
strategy: "interferenceMitigator",
|
|
1667
|
+
strategyName: this.strategyName || this.name,
|
|
1668
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
1669
|
+
action,
|
|
1670
|
+
score: finalScore,
|
|
1671
|
+
reason
|
|
1672
|
+
}
|
|
1673
|
+
]
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
return adjusted;
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1680
|
+
*
|
|
1681
|
+
* Use transform() via Pipeline instead.
|
|
1682
|
+
*/
|
|
1683
|
+
async getWeightedCards(_limit) {
|
|
1684
|
+
throw new Error(
|
|
1685
|
+
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
// src/core/navigators/filters/relativePriority.ts
|
|
1693
|
+
var relativePriority_exports = {};
|
|
1694
|
+
__export(relativePriority_exports, {
|
|
1695
|
+
default: () => RelativePriorityNavigator
|
|
1696
|
+
});
|
|
1697
|
+
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
1698
|
+
var init_relativePriority = __esm({
|
|
1699
|
+
"src/core/navigators/filters/relativePriority.ts"() {
|
|
1700
|
+
"use strict";
|
|
1701
|
+
init_navigators();
|
|
1702
|
+
DEFAULT_PRIORITY = 0.5;
|
|
1703
|
+
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
1704
|
+
DEFAULT_COMBINE_MODE = "max";
|
|
1705
|
+
RelativePriorityNavigator = class extends ContentNavigator {
|
|
1706
|
+
config;
|
|
1707
|
+
/** Human-readable name for CardFilter interface */
|
|
1708
|
+
name;
|
|
1709
|
+
constructor(user, course, strategyData) {
|
|
1710
|
+
super(user, course, strategyData);
|
|
1711
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1712
|
+
this.name = strategyData.name || "Relative Priority";
|
|
1713
|
+
}
|
|
1714
|
+
parseConfig(serializedData) {
|
|
1715
|
+
try {
|
|
1716
|
+
const parsed = JSON.parse(serializedData);
|
|
1717
|
+
return {
|
|
1718
|
+
tagPriorities: parsed.tagPriorities || {},
|
|
1719
|
+
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
1720
|
+
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
1721
|
+
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
1722
|
+
};
|
|
1723
|
+
} catch {
|
|
1724
|
+
return {
|
|
1725
|
+
tagPriorities: {},
|
|
1726
|
+
defaultPriority: DEFAULT_PRIORITY,
|
|
1727
|
+
combineMode: DEFAULT_COMBINE_MODE,
|
|
1728
|
+
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Look up the priority for a tag.
|
|
1734
|
+
*/
|
|
1735
|
+
getTagPriority(tagId) {
|
|
1736
|
+
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Compute combined priority for a card based on its tags.
|
|
1740
|
+
*/
|
|
1741
|
+
computeCardPriority(cardTags) {
|
|
1742
|
+
if (cardTags.length === 0) {
|
|
1743
|
+
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
1744
|
+
}
|
|
1745
|
+
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
1746
|
+
switch (this.config.combineMode) {
|
|
1747
|
+
case "max":
|
|
1748
|
+
return Math.max(...priorities);
|
|
1749
|
+
case "min":
|
|
1750
|
+
return Math.min(...priorities);
|
|
1751
|
+
case "average":
|
|
1752
|
+
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
1753
|
+
default:
|
|
1754
|
+
return Math.max(...priorities);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Compute boost factor based on priority.
|
|
1759
|
+
*
|
|
1760
|
+
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
1761
|
+
*
|
|
1762
|
+
* This creates a multiplier centered around 1.0:
|
|
1763
|
+
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
1764
|
+
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
1765
|
+
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
1766
|
+
*/
|
|
1767
|
+
computeBoostFactor(priority) {
|
|
1768
|
+
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
1769
|
+
return 1 + (priority - 0.5) * influence;
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Build human-readable reason for priority adjustment.
|
|
1773
|
+
*/
|
|
1774
|
+
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
1775
|
+
if (cardTags.length === 0) {
|
|
1776
|
+
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
1777
|
+
}
|
|
1778
|
+
const tagList = cardTags.slice(0, 3).join(", ");
|
|
1779
|
+
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
1780
|
+
if (boostFactor === 1) {
|
|
1781
|
+
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
1782
|
+
} else if (boostFactor > 1) {
|
|
1783
|
+
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
1784
|
+
} else {
|
|
1785
|
+
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* CardFilter.transform implementation.
|
|
1790
|
+
*
|
|
1791
|
+
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
1792
|
+
* cards with low-priority tags get reduced scores.
|
|
1793
|
+
*/
|
|
1794
|
+
async transform(cards, _context) {
|
|
1795
|
+
const adjusted = await Promise.all(
|
|
1796
|
+
cards.map(async (card) => {
|
|
1797
|
+
const cardTags = card.tags ?? [];
|
|
1798
|
+
const priority = this.computeCardPriority(cardTags);
|
|
1799
|
+
const boostFactor = this.computeBoostFactor(priority);
|
|
1800
|
+
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
1801
|
+
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
1802
|
+
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
1803
|
+
return {
|
|
1804
|
+
...card,
|
|
1805
|
+
score: finalScore,
|
|
1806
|
+
provenance: [
|
|
1807
|
+
...card.provenance,
|
|
1808
|
+
{
|
|
1809
|
+
strategy: "relativePriority",
|
|
1810
|
+
strategyName: this.strategyName || this.name,
|
|
1811
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
1812
|
+
action,
|
|
1813
|
+
score: finalScore,
|
|
1814
|
+
reason
|
|
1815
|
+
}
|
|
1816
|
+
]
|
|
1817
|
+
};
|
|
1818
|
+
})
|
|
1819
|
+
);
|
|
1820
|
+
return adjusted;
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1824
|
+
*
|
|
1825
|
+
* Use transform() via Pipeline instead.
|
|
1826
|
+
*/
|
|
1827
|
+
async getWeightedCards(_limit) {
|
|
1828
|
+
throw new Error(
|
|
1829
|
+
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
// src/core/navigators/filters/types.ts
|
|
1837
|
+
var types_exports2 = {};
|
|
1838
|
+
var init_types2 = __esm({
|
|
1839
|
+
"src/core/navigators/filters/types.ts"() {
|
|
1840
|
+
"use strict";
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// src/core/navigators/filters/userGoalStub.ts
|
|
1845
|
+
var userGoalStub_exports = {};
|
|
1846
|
+
__export(userGoalStub_exports, {
|
|
1847
|
+
USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
|
|
1848
|
+
});
|
|
1849
|
+
var USER_GOAL_NAVIGATOR_STUB;
|
|
1850
|
+
var init_userGoalStub = __esm({
|
|
1851
|
+
"src/core/navigators/filters/userGoalStub.ts"() {
|
|
1852
|
+
"use strict";
|
|
1853
|
+
USER_GOAL_NAVIGATOR_STUB = true;
|
|
1854
|
+
}
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
// import("./filters/**/*") in src/core/navigators/index.ts
|
|
1858
|
+
var globImport_filters;
|
|
1859
|
+
var init_2 = __esm({
|
|
1860
|
+
'import("./filters/**/*") in src/core/navigators/index.ts'() {
|
|
1861
|
+
globImport_filters = __glob({
|
|
1862
|
+
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
1863
|
+
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
1864
|
+
"./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
1865
|
+
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
1866
|
+
"./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
|
|
1867
|
+
"./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
1868
|
+
"./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
1869
|
+
"./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
1870
|
+
"./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
|
|
1871
|
+
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
// src/core/orchestration/gradient.ts
|
|
1877
|
+
var init_gradient = __esm({
|
|
1878
|
+
"src/core/orchestration/gradient.ts"() {
|
|
1879
|
+
"use strict";
|
|
1880
|
+
init_logger();
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
// src/core/orchestration/learning.ts
|
|
1885
|
+
var init_learning = __esm({
|
|
1886
|
+
"src/core/orchestration/learning.ts"() {
|
|
1887
|
+
"use strict";
|
|
1888
|
+
init_contentNavigationStrategy();
|
|
1889
|
+
init_types_legacy();
|
|
1890
|
+
init_logger();
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
// src/core/orchestration/signal.ts
|
|
1895
|
+
var init_signal = __esm({
|
|
1896
|
+
"src/core/orchestration/signal.ts"() {
|
|
1897
|
+
"use strict";
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
// src/core/orchestration/recording.ts
|
|
1902
|
+
var init_recording = __esm({
|
|
1903
|
+
"src/core/orchestration/recording.ts"() {
|
|
1904
|
+
"use strict";
|
|
1905
|
+
init_signal();
|
|
1906
|
+
init_types_legacy();
|
|
1907
|
+
init_logger();
|
|
1908
|
+
}
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
// src/core/orchestration/index.ts
|
|
1912
|
+
function fnv1a(str) {
|
|
1913
|
+
let hash = 2166136261;
|
|
1914
|
+
for (let i = 0; i < str.length; i++) {
|
|
1915
|
+
hash ^= str.charCodeAt(i);
|
|
1916
|
+
hash = Math.imul(hash, 16777619);
|
|
1917
|
+
}
|
|
1918
|
+
return hash >>> 0;
|
|
1919
|
+
}
|
|
1920
|
+
function computeDeviation(userId, strategyId, salt) {
|
|
1921
|
+
const input = `${userId}:${strategyId}:${salt}`;
|
|
1922
|
+
const hash = fnv1a(input);
|
|
1923
|
+
const normalized = hash / 4294967296;
|
|
1924
|
+
return normalized * 2 - 1;
|
|
1925
|
+
}
|
|
1926
|
+
function computeSpread(confidence) {
|
|
1927
|
+
const clampedConfidence = Math.max(0, Math.min(1, confidence));
|
|
1928
|
+
return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
|
|
1929
|
+
}
|
|
1930
|
+
function computeEffectiveWeight(learnable, userId, strategyId, salt) {
|
|
1931
|
+
const deviation = computeDeviation(userId, strategyId, salt);
|
|
1932
|
+
const spread = computeSpread(learnable.confidence);
|
|
1933
|
+
const adjustment = deviation * spread * learnable.weight;
|
|
1934
|
+
const effective = learnable.weight + adjustment;
|
|
1935
|
+
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
|
|
1936
|
+
}
|
|
1937
|
+
async function createOrchestrationContext(user, course) {
|
|
1938
|
+
let courseConfig;
|
|
1939
|
+
try {
|
|
1940
|
+
courseConfig = await course.getCourseConfig();
|
|
1941
|
+
} catch (e) {
|
|
1942
|
+
logger.error(`[Orchestration] Failed to load course config: ${e}`);
|
|
1943
|
+
courseConfig = {
|
|
1944
|
+
name: "Unknown",
|
|
1945
|
+
description: "",
|
|
1946
|
+
public: false,
|
|
1947
|
+
deleted: false,
|
|
1948
|
+
creator: "",
|
|
1949
|
+
admins: [],
|
|
1950
|
+
moderators: [],
|
|
1951
|
+
dataShapes: [],
|
|
1952
|
+
questionTypes: [],
|
|
1953
|
+
orchestration: { salt: "default" }
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
const userId = user.getUsername();
|
|
1957
|
+
const salt = courseConfig.orchestration?.salt || "default_salt";
|
|
1958
|
+
return {
|
|
1959
|
+
user,
|
|
1960
|
+
course,
|
|
1961
|
+
userId,
|
|
1962
|
+
courseConfig,
|
|
1963
|
+
getEffectiveWeight(strategyId, learnable) {
|
|
1964
|
+
return computeEffectiveWeight(learnable, userId, strategyId, salt);
|
|
1965
|
+
},
|
|
1966
|
+
getDeviation(strategyId) {
|
|
1967
|
+
return computeDeviation(userId, strategyId, salt);
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
|
|
1972
|
+
var init_orchestration = __esm({
|
|
1973
|
+
"src/core/orchestration/index.ts"() {
|
|
1974
|
+
"use strict";
|
|
1975
|
+
init_logger();
|
|
1976
|
+
init_gradient();
|
|
1977
|
+
init_learning();
|
|
1978
|
+
init_signal();
|
|
1979
|
+
init_recording();
|
|
1980
|
+
MIN_SPREAD = 0.1;
|
|
1981
|
+
MAX_SPREAD = 0.5;
|
|
1982
|
+
MIN_WEIGHT = 0.1;
|
|
1983
|
+
MAX_WEIGHT = 3;
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
// src/core/navigators/Pipeline.ts
|
|
1988
|
+
var Pipeline_exports = {};
|
|
1989
|
+
__export(Pipeline_exports, {
|
|
1990
|
+
Pipeline: () => Pipeline
|
|
1991
|
+
});
|
|
1992
|
+
function logPipelineConfig(generator, filters) {
|
|
1993
|
+
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
1994
|
+
logger.info(
|
|
1995
|
+
`[Pipeline] Configuration:
|
|
1996
|
+
Generator: ${generator.name}
|
|
1997
|
+
Filters:${filterList}`
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
function logTagHydration(cards, tagsByCard) {
|
|
2001
|
+
const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
|
|
2002
|
+
const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
|
|
2003
|
+
logger.debug(
|
|
2004
|
+
`[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
|
|
792
2008
|
const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
|
|
793
2009
|
logger.info(
|
|
794
2010
|
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
|
|
@@ -808,13 +2024,14 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
808
2024
|
}
|
|
809
2025
|
}
|
|
810
2026
|
}
|
|
811
|
-
var
|
|
2027
|
+
var import_common8, Pipeline;
|
|
812
2028
|
var init_Pipeline = __esm({
|
|
813
2029
|
"src/core/navigators/Pipeline.ts"() {
|
|
814
2030
|
"use strict";
|
|
815
|
-
|
|
2031
|
+
import_common8 = require("@vue-skuilder/common");
|
|
816
2032
|
init_navigators();
|
|
817
2033
|
init_logger();
|
|
2034
|
+
init_orchestration();
|
|
818
2035
|
Pipeline = class extends ContentNavigator {
|
|
819
2036
|
generator;
|
|
820
2037
|
filters;
|
|
@@ -917,15 +2134,17 @@ var init_Pipeline = __esm({
|
|
|
917
2134
|
let userElo = 1e3;
|
|
918
2135
|
try {
|
|
919
2136
|
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
920
|
-
const courseElo = (0,
|
|
2137
|
+
const courseElo = (0, import_common8.toCourseElo)(courseReg.elo);
|
|
921
2138
|
userElo = courseElo.global.score;
|
|
922
2139
|
} catch (e) {
|
|
923
2140
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
924
2141
|
}
|
|
2142
|
+
const orchestration = await createOrchestrationContext(this.user, this.course);
|
|
925
2143
|
return {
|
|
926
2144
|
user: this.user,
|
|
927
2145
|
course: this.course,
|
|
928
|
-
userElo
|
|
2146
|
+
userElo,
|
|
2147
|
+
orchestration
|
|
929
2148
|
};
|
|
930
2149
|
}
|
|
931
2150
|
/**
|
|
@@ -934,154 +2153,51 @@ var init_Pipeline = __esm({
|
|
|
934
2153
|
getCourseID() {
|
|
935
2154
|
return this.course.getCourseID();
|
|
936
2155
|
}
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
// src/core/navigators/generators/CompositeGenerator.ts
|
|
942
|
-
var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
943
|
-
var init_CompositeGenerator = __esm({
|
|
944
|
-
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
945
|
-
"use strict";
|
|
946
|
-
init_navigators();
|
|
947
|
-
init_logger();
|
|
948
|
-
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
949
|
-
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
950
|
-
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
951
|
-
/** Human-readable name for CardGenerator interface */
|
|
952
|
-
name = "Composite Generator";
|
|
953
|
-
generators;
|
|
954
|
-
aggregationMode;
|
|
955
|
-
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
956
|
-
super();
|
|
957
|
-
this.generators = generators;
|
|
958
|
-
this.aggregationMode = aggregationMode;
|
|
959
|
-
if (generators.length === 0) {
|
|
960
|
-
throw new Error("CompositeGenerator requires at least one generator");
|
|
961
|
-
}
|
|
962
|
-
logger.debug(
|
|
963
|
-
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
964
|
-
);
|
|
965
|
-
}
|
|
966
|
-
/**
|
|
967
|
-
* Creates a CompositeGenerator from strategy data.
|
|
968
|
-
*
|
|
969
|
-
* This is a convenience factory for use by PipelineAssembler.
|
|
970
|
-
*/
|
|
971
|
-
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
972
|
-
const generators = await Promise.all(
|
|
973
|
-
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
974
|
-
);
|
|
975
|
-
return new _CompositeGenerator(generators, aggregationMode);
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* Get weighted cards from all generators, merge and deduplicate.
|
|
979
|
-
*
|
|
980
|
-
* Cards appearing in multiple generators receive a score boost.
|
|
981
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
982
|
-
*
|
|
983
|
-
* This method supports both the legacy signature (limit only) and the
|
|
984
|
-
* CardGenerator interface signature (limit, context).
|
|
985
|
-
*
|
|
986
|
-
* @param limit - Maximum number of cards to return
|
|
987
|
-
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
988
|
-
*/
|
|
989
|
-
async getWeightedCards(limit, context) {
|
|
990
|
-
if (!context) {
|
|
991
|
-
throw new Error(
|
|
992
|
-
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
993
|
-
);
|
|
994
|
-
}
|
|
995
|
-
const results = await Promise.all(
|
|
996
|
-
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
997
|
-
);
|
|
998
|
-
const byCardId = /* @__PURE__ */ new Map();
|
|
999
|
-
for (const cards of results) {
|
|
1000
|
-
for (const card of cards) {
|
|
1001
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
1002
|
-
existing.push(card);
|
|
1003
|
-
byCardId.set(card.cardId, existing);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
const merged = [];
|
|
1007
|
-
for (const [, cards] of byCardId) {
|
|
1008
|
-
const aggregatedScore = this.aggregateScores(cards);
|
|
1009
|
-
const finalScore = Math.min(1, aggregatedScore);
|
|
1010
|
-
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1011
|
-
const initialScore = cards[0].score;
|
|
1012
|
-
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1013
|
-
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1014
|
-
merged.push({
|
|
1015
|
-
...cards[0],
|
|
1016
|
-
score: finalScore,
|
|
1017
|
-
provenance: [
|
|
1018
|
-
...mergedProvenance,
|
|
1019
|
-
{
|
|
1020
|
-
strategy: "composite",
|
|
1021
|
-
strategyName: "Composite Generator",
|
|
1022
|
-
strategyId: "COMPOSITE_GENERATOR",
|
|
1023
|
-
action,
|
|
1024
|
-
score: finalScore,
|
|
1025
|
-
reason
|
|
1026
|
-
}
|
|
1027
|
-
]
|
|
1028
|
-
});
|
|
1029
|
-
}
|
|
1030
|
-
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1031
|
-
}
|
|
1032
2156
|
/**
|
|
1033
|
-
*
|
|
2157
|
+
* Get orchestration context for outcome recording.
|
|
1034
2158
|
*/
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1038
|
-
if (count === 1) {
|
|
1039
|
-
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1040
|
-
}
|
|
1041
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1042
|
-
switch (this.aggregationMode) {
|
|
1043
|
-
case "max" /* MAX */:
|
|
1044
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1045
|
-
case "average" /* AVERAGE */:
|
|
1046
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1047
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1048
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1049
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1050
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1051
|
-
}
|
|
1052
|
-
default:
|
|
1053
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1054
|
-
}
|
|
2159
|
+
async getOrchestrationContext() {
|
|
2160
|
+
return createOrchestrationContext(this.user, this.course);
|
|
1055
2161
|
}
|
|
1056
2162
|
/**
|
|
1057
|
-
*
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
2163
|
+
* Get IDs of all strategies in this pipeline.
|
|
2164
|
+
* Used to record which strategies contributed to an outcome.
|
|
2165
|
+
*/
|
|
2166
|
+
getStrategyIds() {
|
|
2167
|
+
const ids = [];
|
|
2168
|
+
const extractId = (obj) => {
|
|
2169
|
+
if (obj.strategyId) return obj.strategyId;
|
|
2170
|
+
return null;
|
|
2171
|
+
};
|
|
2172
|
+
const genId = extractId(this.generator);
|
|
2173
|
+
if (genId) ids.push(genId);
|
|
2174
|
+
if (this.generator.generators && Array.isArray(this.generator.generators)) {
|
|
2175
|
+
this.generator.generators.forEach((g) => {
|
|
2176
|
+
const subId = extractId(g);
|
|
2177
|
+
if (subId) ids.push(subId);
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
for (const filter of this.filters) {
|
|
2181
|
+
const fId = extractId(filter);
|
|
2182
|
+
if (fId) ids.push(fId);
|
|
1073
2183
|
}
|
|
2184
|
+
return [...new Set(ids)];
|
|
1074
2185
|
}
|
|
1075
2186
|
};
|
|
1076
2187
|
}
|
|
1077
2188
|
});
|
|
1078
2189
|
|
|
1079
2190
|
// src/core/navigators/PipelineAssembler.ts
|
|
2191
|
+
var PipelineAssembler_exports = {};
|
|
2192
|
+
__export(PipelineAssembler_exports, {
|
|
2193
|
+
PipelineAssembler: () => PipelineAssembler
|
|
2194
|
+
});
|
|
1080
2195
|
var PipelineAssembler;
|
|
1081
2196
|
var init_PipelineAssembler = __esm({
|
|
1082
2197
|
"src/core/navigators/PipelineAssembler.ts"() {
|
|
1083
2198
|
"use strict";
|
|
1084
2199
|
init_navigators();
|
|
2200
|
+
init_WeightedFilter();
|
|
1085
2201
|
init_Pipeline();
|
|
1086
2202
|
init_types_legacy();
|
|
1087
2203
|
init_logger();
|
|
@@ -1143,276 +2259,76 @@ var init_PipelineAssembler = __esm({
|
|
|
1143
2259
|
generator = nav;
|
|
1144
2260
|
logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
|
|
1145
2261
|
} else {
|
|
1146
|
-
logger.debug(
|
|
1147
|
-
`[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
|
|
1148
|
-
);
|
|
1149
|
-
generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
|
|
1150
|
-
}
|
|
1151
|
-
const filters = [];
|
|
1152
|
-
const sortedFilterStrategies = [...filterStrategies].sort(
|
|
1153
|
-
(a, b) => a.name.localeCompare(b.name)
|
|
1154
|
-
);
|
|
1155
|
-
for (const filterStrategy of sortedFilterStrategies) {
|
|
1156
|
-
try {
|
|
1157
|
-
const nav = await ContentNavigator.create(user, course, filterStrategy);
|
|
1158
|
-
if ("transform" in nav && typeof nav.transform === "function") {
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
return {
|
|
1175
|
-
pipeline,
|
|
1176
|
-
generatorStrategies,
|
|
1177
|
-
filterStrategies: sortedFilterStrategies,
|
|
1178
|
-
warnings
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1181
|
-
/**
|
|
1182
|
-
* Creates a default ELO generator strategy.
|
|
1183
|
-
* Used when filters are configured but no generator is specified.
|
|
1184
|
-
*/
|
|
1185
|
-
makeDefaultEloStrategy(courseId) {
|
|
1186
|
-
return {
|
|
1187
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1188
|
-
course: courseId,
|
|
1189
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1190
|
-
name: "ELO (default)",
|
|
1191
|
-
description: "Default ELO-based generator",
|
|
1192
|
-
implementingClass: "elo" /* ELO */,
|
|
1193
|
-
serializedData: ""
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
};
|
|
1197
|
-
}
|
|
1198
|
-
});
|
|
1199
|
-
|
|
1200
|
-
// src/core/navigators/generators/elo.ts
|
|
1201
|
-
var import_common6, ELONavigator;
|
|
1202
|
-
var init_elo = __esm({
|
|
1203
|
-
"src/core/navigators/generators/elo.ts"() {
|
|
1204
|
-
"use strict";
|
|
1205
|
-
init_navigators();
|
|
1206
|
-
import_common6 = require("@vue-skuilder/common");
|
|
1207
|
-
ELONavigator = class extends ContentNavigator {
|
|
1208
|
-
/** Human-readable name for CardGenerator interface */
|
|
1209
|
-
name;
|
|
1210
|
-
constructor(user, course, strategyData) {
|
|
1211
|
-
super(user, course, strategyData);
|
|
1212
|
-
this.name = strategyData?.name || "ELO";
|
|
1213
|
-
}
|
|
1214
|
-
/**
|
|
1215
|
-
* Get new cards with suitability scores based on ELO distance.
|
|
1216
|
-
*
|
|
1217
|
-
* Cards closer to user's ELO get higher scores.
|
|
1218
|
-
* Score formula: max(0, 1 - distance / 500)
|
|
1219
|
-
*
|
|
1220
|
-
* NOTE: This generator only handles NEW cards. Reviews are handled by
|
|
1221
|
-
* SRSNavigator. Use CompositeGenerator to combine both.
|
|
1222
|
-
*
|
|
1223
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1224
|
-
* CardGenerator interface signature (limit, context).
|
|
1225
|
-
*
|
|
1226
|
-
* @param limit - Maximum number of cards to return
|
|
1227
|
-
* @param context - Optional GeneratorContext (used when called via Pipeline)
|
|
1228
|
-
*/
|
|
1229
|
-
async getWeightedCards(limit, context) {
|
|
1230
|
-
let userGlobalElo;
|
|
1231
|
-
if (context?.userElo !== void 0) {
|
|
1232
|
-
userGlobalElo = context.userElo;
|
|
1233
|
-
} else {
|
|
1234
|
-
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
1235
|
-
const userElo = (0, import_common6.toCourseElo)(courseReg.elo);
|
|
1236
|
-
userGlobalElo = userElo.global.score;
|
|
1237
|
-
}
|
|
1238
|
-
const activeCards = await this.user.getActiveCards();
|
|
1239
|
-
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
1240
|
-
{ limit, elo: "user" },
|
|
1241
|
-
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
1242
|
-
)).map((c) => ({ ...c, status: "new" }));
|
|
1243
|
-
const cardIds = newCards.map((c) => c.cardID);
|
|
1244
|
-
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1245
|
-
const scored = newCards.map((c, i) => {
|
|
1246
|
-
const cardElo = cardEloData[i]?.global?.score ?? 1e3;
|
|
1247
|
-
const distance = Math.abs(cardElo - userGlobalElo);
|
|
1248
|
-
const score = Math.max(0, 1 - distance / 500);
|
|
1249
|
-
return {
|
|
1250
|
-
cardId: c.cardID,
|
|
1251
|
-
courseId: c.courseID,
|
|
1252
|
-
score,
|
|
1253
|
-
provenance: [
|
|
1254
|
-
{
|
|
1255
|
-
strategy: "elo",
|
|
1256
|
-
strategyName: this.strategyName || this.name,
|
|
1257
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
|
|
1258
|
-
action: "generated",
|
|
1259
|
-
score,
|
|
1260
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`
|
|
1261
|
-
}
|
|
1262
|
-
]
|
|
1263
|
-
};
|
|
1264
|
-
});
|
|
1265
|
-
scored.sort((a, b) => b.score - a.score);
|
|
1266
|
-
return scored.slice(0, limit);
|
|
1267
|
-
}
|
|
1268
|
-
};
|
|
1269
|
-
}
|
|
1270
|
-
});
|
|
1271
|
-
|
|
1272
|
-
// src/core/navigators/generators/srs.ts
|
|
1273
|
-
var import_moment, SRSNavigator;
|
|
1274
|
-
var init_srs = __esm({
|
|
1275
|
-
"src/core/navigators/generators/srs.ts"() {
|
|
1276
|
-
"use strict";
|
|
1277
|
-
import_moment = __toESM(require("moment"), 1);
|
|
1278
|
-
init_navigators();
|
|
1279
|
-
init_logger();
|
|
1280
|
-
SRSNavigator = class extends ContentNavigator {
|
|
1281
|
-
/** Human-readable name for CardGenerator interface */
|
|
1282
|
-
name;
|
|
1283
|
-
constructor(user, course, strategyData) {
|
|
1284
|
-
super(user, course, strategyData);
|
|
1285
|
-
this.name = strategyData?.name || "SRS";
|
|
1286
|
-
}
|
|
1287
|
-
/**
|
|
1288
|
-
* Get review cards scored by urgency.
|
|
1289
|
-
*
|
|
1290
|
-
* Score formula combines:
|
|
1291
|
-
* - Relative overdueness: hoursOverdue / intervalHours
|
|
1292
|
-
* - Interval recency: exponential decay favoring shorter intervals
|
|
1293
|
-
*
|
|
1294
|
-
* Cards not yet due are excluded (not scored as 0).
|
|
1295
|
-
*
|
|
1296
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1297
|
-
* CardGenerator interface signature (limit, context).
|
|
1298
|
-
*
|
|
1299
|
-
* @param limit - Maximum number of cards to return
|
|
1300
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1301
|
-
*/
|
|
1302
|
-
async getWeightedCards(limit, _context) {
|
|
1303
|
-
if (!this.user || !this.course) {
|
|
1304
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
1305
|
-
}
|
|
1306
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1307
|
-
const now = import_moment.default.utc();
|
|
1308
|
-
const dueReviews = reviews.filter((r) => now.isAfter(import_moment.default.utc(r.reviewTime)));
|
|
1309
|
-
const scored = dueReviews.map((review) => {
|
|
1310
|
-
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
1311
|
-
return {
|
|
1312
|
-
cardId: review.cardId,
|
|
1313
|
-
courseId: review.courseId,
|
|
1314
|
-
score,
|
|
1315
|
-
reviewID: review._id,
|
|
1316
|
-
provenance: [
|
|
1317
|
-
{
|
|
1318
|
-
strategy: "srs",
|
|
1319
|
-
strategyName: this.strategyName || this.name,
|
|
1320
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
|
|
1321
|
-
action: "generated",
|
|
1322
|
-
score,
|
|
1323
|
-
reason
|
|
1324
|
-
}
|
|
1325
|
-
]
|
|
1326
|
-
};
|
|
1327
|
-
});
|
|
1328
|
-
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
1329
|
-
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1330
|
-
}
|
|
1331
|
-
/**
|
|
1332
|
-
* Compute urgency score for a review card.
|
|
1333
|
-
*
|
|
1334
|
-
* Two factors:
|
|
1335
|
-
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
1336
|
-
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
1337
|
-
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
1338
|
-
*
|
|
1339
|
-
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
1340
|
-
* - 24h interval → ~1.0 (very recent learning)
|
|
1341
|
-
* - 30 days (720h) → ~0.56
|
|
1342
|
-
* - 180 days → ~0.30
|
|
1343
|
-
*
|
|
1344
|
-
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
1345
|
-
* Result range: approximately 0.5 to 0.95
|
|
1346
|
-
*/
|
|
1347
|
-
computeUrgencyScore(review, now) {
|
|
1348
|
-
const scheduledAt = import_moment.default.utc(review.scheduledAt);
|
|
1349
|
-
const due = import_moment.default.utc(review.reviewTime);
|
|
1350
|
-
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
1351
|
-
const hoursOverdue = now.diff(due, "hours");
|
|
1352
|
-
const relativeOverdue = hoursOverdue / intervalHours;
|
|
1353
|
-
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1354
|
-
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1355
|
-
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1356
|
-
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
1357
|
-
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
1358
|
-
return { score, reason };
|
|
1359
|
-
}
|
|
1360
|
-
};
|
|
1361
|
-
}
|
|
1362
|
-
});
|
|
1363
|
-
|
|
1364
|
-
// src/core/navigators/filters/eloDistance.ts
|
|
1365
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1366
|
-
const normalizedDistance = distance / halfLife;
|
|
1367
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1368
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1369
|
-
}
|
|
1370
|
-
function createEloDistanceFilter(config) {
|
|
1371
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1372
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1373
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1374
|
-
return {
|
|
1375
|
-
name: "ELO Distance Filter",
|
|
1376
|
-
async transform(cards, context) {
|
|
1377
|
-
const { course, userElo } = context;
|
|
1378
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1379
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1380
|
-
return cards.map((card, i) => {
|
|
1381
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1382
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1383
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1384
|
-
const newScore = card.score * multiplier;
|
|
1385
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1386
|
-
return {
|
|
1387
|
-
...card,
|
|
1388
|
-
score: newScore,
|
|
1389
|
-
provenance: [
|
|
1390
|
-
...card.provenance,
|
|
1391
|
-
{
|
|
1392
|
-
strategy: "eloDistance",
|
|
1393
|
-
strategyName: "ELO Distance Filter",
|
|
1394
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1395
|
-
action,
|
|
1396
|
-
score: newScore,
|
|
1397
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
2262
|
+
logger.debug(
|
|
2263
|
+
`[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
|
|
2264
|
+
);
|
|
2265
|
+
generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
|
|
2266
|
+
}
|
|
2267
|
+
const filters = [];
|
|
2268
|
+
const sortedFilterStrategies = [...filterStrategies].sort(
|
|
2269
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
2270
|
+
);
|
|
2271
|
+
for (const filterStrategy of sortedFilterStrategies) {
|
|
2272
|
+
try {
|
|
2273
|
+
const nav = await ContentNavigator.create(user, course, filterStrategy);
|
|
2274
|
+
if ("transform" in nav && typeof nav.transform === "function") {
|
|
2275
|
+
let filter = nav;
|
|
2276
|
+
if (filterStrategy.learnable) {
|
|
2277
|
+
filter = new WeightedFilter(
|
|
2278
|
+
filter,
|
|
2279
|
+
filterStrategy.learnable,
|
|
2280
|
+
filterStrategy.staticWeight,
|
|
2281
|
+
filterStrategy._id
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
filters.push(filter);
|
|
2285
|
+
logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
|
|
2286
|
+
} else {
|
|
2287
|
+
warnings.push(
|
|
2288
|
+
`Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
|
|
2289
|
+
);
|
|
1398
2290
|
}
|
|
1399
|
-
|
|
2291
|
+
} catch (e) {
|
|
2292
|
+
warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
const pipeline = new Pipeline(generator, filters, user, course);
|
|
2296
|
+
logger.debug(
|
|
2297
|
+
`[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
|
|
2298
|
+
);
|
|
2299
|
+
return {
|
|
2300
|
+
pipeline,
|
|
2301
|
+
generatorStrategies,
|
|
2302
|
+
filterStrategies: sortedFilterStrategies,
|
|
2303
|
+
warnings
|
|
1400
2304
|
};
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* Creates a default ELO generator strategy.
|
|
2308
|
+
* Used when filters are configured but no generator is specified.
|
|
2309
|
+
*/
|
|
2310
|
+
makeDefaultEloStrategy(courseId) {
|
|
2311
|
+
return {
|
|
2312
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
2313
|
+
course: courseId,
|
|
2314
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
2315
|
+
name: "ELO (default)",
|
|
2316
|
+
description: "Default ELO-based generator",
|
|
2317
|
+
implementingClass: "elo" /* ELO */,
|
|
2318
|
+
serializedData: ""
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
1412
2322
|
}
|
|
1413
2323
|
});
|
|
1414
2324
|
|
|
1415
2325
|
// src/core/navigators/defaults.ts
|
|
2326
|
+
var defaults_exports = {};
|
|
2327
|
+
__export(defaults_exports, {
|
|
2328
|
+
createDefaultEloStrategy: () => createDefaultEloStrategy,
|
|
2329
|
+
createDefaultPipeline: () => createDefaultPipeline,
|
|
2330
|
+
createDefaultSrsStrategy: () => createDefaultSrsStrategy
|
|
2331
|
+
});
|
|
1416
2332
|
function createDefaultEloStrategy(courseId) {
|
|
1417
2333
|
return {
|
|
1418
2334
|
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
@@ -1456,6 +2372,304 @@ var init_defaults = __esm({
|
|
|
1456
2372
|
}
|
|
1457
2373
|
});
|
|
1458
2374
|
|
|
2375
|
+
// import("./**/*") in src/core/navigators/index.ts
|
|
2376
|
+
var globImport;
|
|
2377
|
+
var init_3 = __esm({
|
|
2378
|
+
'import("./**/*") in src/core/navigators/index.ts'() {
|
|
2379
|
+
globImport = __glob({
|
|
2380
|
+
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2381
|
+
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
2382
|
+
"./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
|
|
2383
|
+
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
2384
|
+
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2385
|
+
"./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2386
|
+
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2387
|
+
"./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
|
|
2388
|
+
"./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2389
|
+
"./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2390
|
+
"./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2391
|
+
"./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
|
|
2392
|
+
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
|
|
2393
|
+
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2394
|
+
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2395
|
+
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2396
|
+
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2397
|
+
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2398
|
+
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
// src/core/navigators/index.ts
|
|
2404
|
+
var navigators_exports = {};
|
|
2405
|
+
__export(navigators_exports, {
|
|
2406
|
+
ContentNavigator: () => ContentNavigator,
|
|
2407
|
+
NavigatorRole: () => NavigatorRole,
|
|
2408
|
+
NavigatorRoles: () => NavigatorRoles,
|
|
2409
|
+
Navigators: () => Navigators,
|
|
2410
|
+
getCardOrigin: () => getCardOrigin,
|
|
2411
|
+
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
2412
|
+
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
2413
|
+
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
2414
|
+
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
2415
|
+
isFilter: () => isFilter,
|
|
2416
|
+
isGenerator: () => isGenerator,
|
|
2417
|
+
registerNavigator: () => registerNavigator
|
|
2418
|
+
});
|
|
2419
|
+
function registerNavigator(implementingClass, constructor) {
|
|
2420
|
+
navigatorRegistry.set(implementingClass, constructor);
|
|
2421
|
+
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
|
|
2422
|
+
}
|
|
2423
|
+
function getRegisteredNavigator(implementingClass) {
|
|
2424
|
+
return navigatorRegistry.get(implementingClass);
|
|
2425
|
+
}
|
|
2426
|
+
function hasRegisteredNavigator(implementingClass) {
|
|
2427
|
+
return navigatorRegistry.has(implementingClass);
|
|
2428
|
+
}
|
|
2429
|
+
function getRegisteredNavigatorNames() {
|
|
2430
|
+
return Array.from(navigatorRegistry.keys());
|
|
2431
|
+
}
|
|
2432
|
+
async function initializeNavigatorRegistry() {
|
|
2433
|
+
logger.debug("[NavigatorRegistry] Initializing built-in navigators...");
|
|
2434
|
+
const [eloModule, srsModule] = await Promise.all([
|
|
2435
|
+
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2436
|
+
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
2437
|
+
]);
|
|
2438
|
+
registerNavigator("elo", eloModule.default);
|
|
2439
|
+
registerNavigator("srs", srsModule.default);
|
|
2440
|
+
const [
|
|
2441
|
+
hierarchyModule,
|
|
2442
|
+
interferenceModule,
|
|
2443
|
+
relativePriorityModule,
|
|
2444
|
+
userTagPreferenceModule
|
|
2445
|
+
] = await Promise.all([
|
|
2446
|
+
Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2447
|
+
Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2448
|
+
Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2449
|
+
Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
|
|
2450
|
+
]);
|
|
2451
|
+
registerNavigator("hierarchyDefinition", hierarchyModule.default);
|
|
2452
|
+
registerNavigator("interferenceMitigator", interferenceModule.default);
|
|
2453
|
+
registerNavigator("relativePriority", relativePriorityModule.default);
|
|
2454
|
+
registerNavigator("userTagPreference", userTagPreferenceModule.default);
|
|
2455
|
+
logger.debug(
|
|
2456
|
+
`[NavigatorRegistry] Initialized ${navigatorRegistry.size} navigators: ${getRegisteredNavigatorNames().join(", ")}`
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
function getCardOrigin(card) {
|
|
2460
|
+
if (card.provenance.length === 0) {
|
|
2461
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
2462
|
+
}
|
|
2463
|
+
const firstEntry = card.provenance[0];
|
|
2464
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
2465
|
+
if (reason.includes("failed")) {
|
|
2466
|
+
return "failed";
|
|
2467
|
+
}
|
|
2468
|
+
if (reason.includes("review")) {
|
|
2469
|
+
return "review";
|
|
2470
|
+
}
|
|
2471
|
+
return "new";
|
|
2472
|
+
}
|
|
2473
|
+
function isGenerator(impl) {
|
|
2474
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
2475
|
+
}
|
|
2476
|
+
function isFilter(impl) {
|
|
2477
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
2478
|
+
}
|
|
2479
|
+
var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
2480
|
+
var init_navigators = __esm({
|
|
2481
|
+
"src/core/navigators/index.ts"() {
|
|
2482
|
+
"use strict";
|
|
2483
|
+
init_logger();
|
|
2484
|
+
init_();
|
|
2485
|
+
init_2();
|
|
2486
|
+
init_3();
|
|
2487
|
+
navigatorRegistry = /* @__PURE__ */ new Map();
|
|
2488
|
+
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2489
|
+
Navigators2["ELO"] = "elo";
|
|
2490
|
+
Navigators2["SRS"] = "srs";
|
|
2491
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2492
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2493
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
2494
|
+
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
2495
|
+
return Navigators2;
|
|
2496
|
+
})(Navigators || {});
|
|
2497
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
2498
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
2499
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
2500
|
+
return NavigatorRole2;
|
|
2501
|
+
})(NavigatorRole || {});
|
|
2502
|
+
NavigatorRoles = {
|
|
2503
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2504
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
2505
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2506
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2507
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
2508
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
2509
|
+
};
|
|
2510
|
+
ContentNavigator = class {
|
|
2511
|
+
/** User interface for this navigation session */
|
|
2512
|
+
user;
|
|
2513
|
+
/** Course interface for this navigation session */
|
|
2514
|
+
course;
|
|
2515
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
2516
|
+
strategyName;
|
|
2517
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
2518
|
+
strategyId;
|
|
2519
|
+
/** Evolutionary weighting configuration */
|
|
2520
|
+
learnable;
|
|
2521
|
+
/** Whether to bypass deviation (manual/static weighting) */
|
|
2522
|
+
staticWeight;
|
|
2523
|
+
/**
|
|
2524
|
+
* Constructor for standard navigators.
|
|
2525
|
+
* Call this from subclass constructors to initialize common fields.
|
|
2526
|
+
*
|
|
2527
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
2528
|
+
* user/course fields directly if needed.
|
|
2529
|
+
*/
|
|
2530
|
+
constructor(user, course, strategyData) {
|
|
2531
|
+
this.user = user;
|
|
2532
|
+
this.course = course;
|
|
2533
|
+
if (strategyData) {
|
|
2534
|
+
this.strategyName = strategyData.name;
|
|
2535
|
+
this.strategyId = strategyData._id;
|
|
2536
|
+
this.learnable = strategyData.learnable;
|
|
2537
|
+
this.staticWeight = strategyData.staticWeight;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
// ============================================================================
|
|
2541
|
+
// STRATEGY STATE HELPERS
|
|
2542
|
+
// ============================================================================
|
|
2543
|
+
//
|
|
2544
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
2545
|
+
// learned patterns, temporal tracking) in the user database.
|
|
2546
|
+
//
|
|
2547
|
+
// ============================================================================
|
|
2548
|
+
/**
|
|
2549
|
+
* Unique key identifying this strategy for state storage.
|
|
2550
|
+
*
|
|
2551
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
2552
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
2553
|
+
* need separate state storage.
|
|
2554
|
+
*/
|
|
2555
|
+
get strategyKey() {
|
|
2556
|
+
return this.constructor.name;
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* Get this strategy's persisted state for the current course.
|
|
2560
|
+
*
|
|
2561
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
2562
|
+
* @throws Error if user or course is not initialized
|
|
2563
|
+
*/
|
|
2564
|
+
async getStrategyState() {
|
|
2565
|
+
if (!this.user || !this.course) {
|
|
2566
|
+
throw new Error(
|
|
2567
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* Persist this strategy's state for the current course.
|
|
2574
|
+
*
|
|
2575
|
+
* @param data - The strategy's data payload to store
|
|
2576
|
+
* @throws Error if user or course is not initialized
|
|
2577
|
+
*/
|
|
2578
|
+
async putStrategyState(data) {
|
|
2579
|
+
if (!this.user || !this.course) {
|
|
2580
|
+
throw new Error(
|
|
2581
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2584
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Factory method to create navigator instances.
|
|
2588
|
+
*
|
|
2589
|
+
* First checks the navigator registry for a pre-registered constructor.
|
|
2590
|
+
* If not found, falls back to dynamic import (for custom navigators).
|
|
2591
|
+
*
|
|
2592
|
+
* For reliable operation in test environments, call initializeNavigatorRegistry()
|
|
2593
|
+
* before using this method.
|
|
2594
|
+
*
|
|
2595
|
+
* @param user - User interface
|
|
2596
|
+
* @param course - Course interface
|
|
2597
|
+
* @param strategyData - Strategy configuration document
|
|
2598
|
+
* @returns the runtime object used to steer a study session.
|
|
2599
|
+
*/
|
|
2600
|
+
static async create(user, course, strategyData) {
|
|
2601
|
+
const implementingClass = strategyData.implementingClass;
|
|
2602
|
+
const RegisteredImpl = getRegisteredNavigator(implementingClass);
|
|
2603
|
+
if (RegisteredImpl) {
|
|
2604
|
+
logger.debug(`[ContentNavigator.create] Using registered navigator: ${implementingClass}`);
|
|
2605
|
+
return new RegisteredImpl(user, course, strategyData);
|
|
2606
|
+
}
|
|
2607
|
+
logger.debug(
|
|
2608
|
+
`[ContentNavigator.create] Navigator not in registry, attempting dynamic import: ${implementingClass}`
|
|
2609
|
+
);
|
|
2610
|
+
let NavigatorImpl;
|
|
2611
|
+
const variations = [".ts", ".js", ""];
|
|
2612
|
+
for (const ext of variations) {
|
|
2613
|
+
try {
|
|
2614
|
+
const module2 = await globImport_generators(`./generators/${implementingClass}${ext}`);
|
|
2615
|
+
NavigatorImpl = module2.default;
|
|
2616
|
+
if (NavigatorImpl) break;
|
|
2617
|
+
} catch (e) {
|
|
2618
|
+
logger.debug(`Failed to load generator ${implementingClass}${ext}:`, e);
|
|
2619
|
+
}
|
|
2620
|
+
try {
|
|
2621
|
+
const module2 = await globImport_filters(`./filters/${implementingClass}${ext}`);
|
|
2622
|
+
NavigatorImpl = module2.default;
|
|
2623
|
+
if (NavigatorImpl) break;
|
|
2624
|
+
} catch (e) {
|
|
2625
|
+
logger.debug(`Failed to load filter ${implementingClass}${ext}:`, e);
|
|
2626
|
+
}
|
|
2627
|
+
try {
|
|
2628
|
+
const module2 = await globImport(`./${implementingClass}${ext}`);
|
|
2629
|
+
NavigatorImpl = module2.default;
|
|
2630
|
+
if (NavigatorImpl) break;
|
|
2631
|
+
} catch (e) {
|
|
2632
|
+
logger.debug(`Failed to load legacy ${implementingClass}${ext}:`, e);
|
|
2633
|
+
}
|
|
2634
|
+
if (NavigatorImpl) break;
|
|
2635
|
+
}
|
|
2636
|
+
if (!NavigatorImpl) {
|
|
2637
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
2638
|
+
}
|
|
2639
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Get cards with suitability scores and provenance trails.
|
|
2643
|
+
*
|
|
2644
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
2645
|
+
*
|
|
2646
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
2647
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
2648
|
+
* documenting how strategies contributed to the final score.
|
|
2649
|
+
*
|
|
2650
|
+
* ## Implementation Required
|
|
2651
|
+
* All navigation strategies MUST override this method. The base class does
|
|
2652
|
+
* not provide a default implementation.
|
|
2653
|
+
*
|
|
2654
|
+
* ## For Generators
|
|
2655
|
+
* Override this method to generate candidates and compute scores based on
|
|
2656
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2657
|
+
* initial provenance entry with action='generated'.
|
|
2658
|
+
*
|
|
2659
|
+
* ## For Filters
|
|
2660
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
2661
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
2662
|
+
*
|
|
2663
|
+
* @param limit - Maximum cards to return
|
|
2664
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
2665
|
+
*/
|
|
2666
|
+
async getWeightedCards(_limit) {
|
|
2667
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
2668
|
+
}
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
});
|
|
2672
|
+
|
|
1459
2673
|
// src/impl/couch/courseDB.ts
|
|
1460
2674
|
function randIntWeightedTowardZero(n) {
|
|
1461
2675
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -1574,11 +2788,11 @@ ${JSON.stringify(config)}
|
|
|
1574
2788
|
function isSuccessRow(row) {
|
|
1575
2789
|
return "doc" in row && row.doc !== null && row.doc !== void 0;
|
|
1576
2790
|
}
|
|
1577
|
-
var
|
|
2791
|
+
var import_common9, CoursesDB, CourseDB;
|
|
1578
2792
|
var init_courseDB = __esm({
|
|
1579
2793
|
"src/impl/couch/courseDB.ts"() {
|
|
1580
2794
|
"use strict";
|
|
1581
|
-
|
|
2795
|
+
import_common9 = require("@vue-skuilder/common");
|
|
1582
2796
|
init_couch();
|
|
1583
2797
|
init_updateQueue();
|
|
1584
2798
|
init_types_legacy();
|
|
@@ -1700,14 +2914,14 @@ var init_courseDB = __esm({
|
|
|
1700
2914
|
docs.rows.forEach((r) => {
|
|
1701
2915
|
if (isSuccessRow(r)) {
|
|
1702
2916
|
if (r.doc && r.doc.elo) {
|
|
1703
|
-
ret.push((0,
|
|
2917
|
+
ret.push((0, import_common9.toCourseElo)(r.doc.elo));
|
|
1704
2918
|
} else {
|
|
1705
2919
|
logger.warn("no elo data for card: " + r.id);
|
|
1706
|
-
ret.push((0,
|
|
2920
|
+
ret.push((0, import_common9.blankCourseElo)());
|
|
1707
2921
|
}
|
|
1708
2922
|
} else {
|
|
1709
2923
|
logger.warn("no elo data for card: " + JSON.stringify(r));
|
|
1710
|
-
ret.push((0,
|
|
2924
|
+
ret.push((0, import_common9.blankCourseElo)());
|
|
1711
2925
|
}
|
|
1712
2926
|
});
|
|
1713
2927
|
return ret;
|
|
@@ -1902,7 +3116,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
1902
3116
|
async getCourseTagStubs() {
|
|
1903
3117
|
return getCourseTagStubs(this.id);
|
|
1904
3118
|
}
|
|
1905
|
-
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0,
|
|
3119
|
+
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common9.blankCourseElo)()) {
|
|
1906
3120
|
try {
|
|
1907
3121
|
const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
|
|
1908
3122
|
if (resp.ok) {
|
|
@@ -1911,19 +3125,19 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
1911
3125
|
`[courseDB.addNote] Note added but card creation failed: ${resp.cardCreationError}`
|
|
1912
3126
|
);
|
|
1913
3127
|
return {
|
|
1914
|
-
status:
|
|
3128
|
+
status: import_common9.Status.error,
|
|
1915
3129
|
message: `Note was added but no cards were created: ${resp.cardCreationError}`,
|
|
1916
3130
|
id: resp.id
|
|
1917
3131
|
};
|
|
1918
3132
|
}
|
|
1919
3133
|
return {
|
|
1920
|
-
status:
|
|
3134
|
+
status: import_common9.Status.ok,
|
|
1921
3135
|
message: "",
|
|
1922
3136
|
id: resp.id
|
|
1923
3137
|
};
|
|
1924
3138
|
} else {
|
|
1925
3139
|
return {
|
|
1926
|
-
status:
|
|
3140
|
+
status: import_common9.Status.error,
|
|
1927
3141
|
message: "Unexpected error adding note"
|
|
1928
3142
|
};
|
|
1929
3143
|
}
|
|
@@ -1935,7 +3149,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
1935
3149
|
message: ${err.message}`
|
|
1936
3150
|
);
|
|
1937
3151
|
return {
|
|
1938
|
-
status:
|
|
3152
|
+
status: import_common9.Status.error,
|
|
1939
3153
|
message: `Error adding note to course. ${e.reason || err.message}`
|
|
1940
3154
|
};
|
|
1941
3155
|
}
|
|
@@ -2063,7 +3277,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2063
3277
|
const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
|
|
2064
3278
|
return c.courseID === this.id;
|
|
2065
3279
|
});
|
|
2066
|
-
targetElo = (0,
|
|
3280
|
+
targetElo = (0, import_common9.EloToNumber)(courseDoc.elo);
|
|
2067
3281
|
} catch {
|
|
2068
3282
|
targetElo = 1e3;
|
|
2069
3283
|
}
|
|
@@ -2475,11 +3689,11 @@ var init_classroomDB2 = __esm({
|
|
|
2475
3689
|
});
|
|
2476
3690
|
|
|
2477
3691
|
// src/study/TagFilteredContentSource.ts
|
|
2478
|
-
var
|
|
3692
|
+
var import_common10, TagFilteredContentSource;
|
|
2479
3693
|
var init_TagFilteredContentSource = __esm({
|
|
2480
3694
|
"src/study/TagFilteredContentSource.ts"() {
|
|
2481
3695
|
"use strict";
|
|
2482
|
-
|
|
3696
|
+
import_common10 = require("@vue-skuilder/common");
|
|
2483
3697
|
init_courseDB();
|
|
2484
3698
|
init_logger();
|
|
2485
3699
|
TagFilteredContentSource = class {
|
|
@@ -2565,7 +3779,7 @@ var init_TagFilteredContentSource = __esm({
|
|
|
2565
3779
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
2566
3780
|
*/
|
|
2567
3781
|
async getWeightedCards(limit) {
|
|
2568
|
-
if (!(0,
|
|
3782
|
+
if (!(0, import_common10.hasActiveFilter)(this.filter)) {
|
|
2569
3783
|
logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
|
|
2570
3784
|
return [];
|
|
2571
3785
|
}
|
|
@@ -2653,19 +3867,19 @@ async function getStudySource(source, user) {
|
|
|
2653
3867
|
if (source.type === "classroom") {
|
|
2654
3868
|
return await StudentClassroomDB.factory(source.id, user);
|
|
2655
3869
|
} else {
|
|
2656
|
-
if ((0,
|
|
3870
|
+
if ((0, import_common11.hasActiveFilter)(source.tagFilter)) {
|
|
2657
3871
|
return new TagFilteredContentSource(source.id, source.tagFilter, user);
|
|
2658
3872
|
}
|
|
2659
3873
|
return getDataLayer().getCourseDB(source.id);
|
|
2660
3874
|
}
|
|
2661
3875
|
}
|
|
2662
|
-
var
|
|
3876
|
+
var import_common11;
|
|
2663
3877
|
var init_contentSource = __esm({
|
|
2664
3878
|
"src/core/interfaces/contentSource.ts"() {
|
|
2665
3879
|
"use strict";
|
|
2666
3880
|
init_factory();
|
|
2667
3881
|
init_classroomDB2();
|
|
2668
|
-
|
|
3882
|
+
import_common11 = require("@vue-skuilder/common");
|
|
2669
3883
|
init_TagFilteredContentSource();
|
|
2670
3884
|
}
|
|
2671
3885
|
});
|
|
@@ -2721,6 +3935,13 @@ var init_strategyState = __esm({
|
|
|
2721
3935
|
}
|
|
2722
3936
|
});
|
|
2723
3937
|
|
|
3938
|
+
// src/core/types/userOutcome.ts
|
|
3939
|
+
var init_userOutcome = __esm({
|
|
3940
|
+
"src/core/types/userOutcome.ts"() {
|
|
3941
|
+
"use strict";
|
|
3942
|
+
}
|
|
3943
|
+
});
|
|
3944
|
+
|
|
2724
3945
|
// src/core/util/index.ts
|
|
2725
3946
|
function getCardHistoryID(courseID, cardID) {
|
|
2726
3947
|
return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
|
|
@@ -2733,17 +3954,17 @@ var init_util = __esm({
|
|
|
2733
3954
|
});
|
|
2734
3955
|
|
|
2735
3956
|
// src/core/bulkImport/cardProcessor.ts
|
|
2736
|
-
var
|
|
3957
|
+
var import_common12;
|
|
2737
3958
|
var init_cardProcessor = __esm({
|
|
2738
3959
|
"src/core/bulkImport/cardProcessor.ts"() {
|
|
2739
3960
|
"use strict";
|
|
2740
|
-
|
|
3961
|
+
import_common12 = require("@vue-skuilder/common");
|
|
2741
3962
|
init_logger();
|
|
2742
3963
|
}
|
|
2743
3964
|
});
|
|
2744
3965
|
|
|
2745
3966
|
// src/core/bulkImport/types.ts
|
|
2746
|
-
var
|
|
3967
|
+
var init_types3 = __esm({
|
|
2747
3968
|
"src/core/bulkImport/types.ts"() {
|
|
2748
3969
|
"use strict";
|
|
2749
3970
|
}
|
|
@@ -2754,7 +3975,7 @@ var init_bulkImport = __esm({
|
|
|
2754
3975
|
"src/core/bulkImport/index.ts"() {
|
|
2755
3976
|
"use strict";
|
|
2756
3977
|
init_cardProcessor();
|
|
2757
|
-
|
|
3978
|
+
init_types3();
|
|
2758
3979
|
}
|
|
2759
3980
|
});
|
|
2760
3981
|
|
|
@@ -2766,10 +3987,12 @@ var init_core = __esm({
|
|
|
2766
3987
|
init_types_legacy();
|
|
2767
3988
|
init_user();
|
|
2768
3989
|
init_strategyState();
|
|
3990
|
+
init_userOutcome();
|
|
2769
3991
|
init_Loggable();
|
|
2770
3992
|
init_util();
|
|
2771
3993
|
init_navigators();
|
|
2772
3994
|
init_bulkImport();
|
|
3995
|
+
init_orchestration();
|
|
2773
3996
|
}
|
|
2774
3997
|
});
|
|
2775
3998
|
|
|
@@ -3115,13 +4338,13 @@ async function dropUserFromClassroom(user, classID) {
|
|
|
3115
4338
|
async function getUserClassrooms(user) {
|
|
3116
4339
|
return getOrCreateClassroomRegistrationsDoc(user);
|
|
3117
4340
|
}
|
|
3118
|
-
var
|
|
4341
|
+
var import_common13, import_moment5, log3, BaseUser, userCoursesDoc, userClassroomsDoc;
|
|
3119
4342
|
var init_BaseUserDB = __esm({
|
|
3120
4343
|
"src/impl/common/BaseUserDB.ts"() {
|
|
3121
4344
|
"use strict";
|
|
3122
4345
|
init_core();
|
|
3123
4346
|
init_util();
|
|
3124
|
-
|
|
4347
|
+
import_common13 = require("@vue-skuilder/common");
|
|
3125
4348
|
import_moment5 = __toESM(require("moment"), 1);
|
|
3126
4349
|
init_types_legacy();
|
|
3127
4350
|
init_logger();
|
|
@@ -3171,7 +4394,7 @@ Currently logged-in as ${this._username}.`
|
|
|
3171
4394
|
);
|
|
3172
4395
|
}
|
|
3173
4396
|
const result = await this.syncStrategy.createAccount(username, password);
|
|
3174
|
-
if (result.status ===
|
|
4397
|
+
if (result.status === import_common13.Status.ok) {
|
|
3175
4398
|
log3(`Account created successfully, updating username to ${username}`);
|
|
3176
4399
|
this._username = username;
|
|
3177
4400
|
try {
|
|
@@ -3213,7 +4436,7 @@ Currently logged-in as ${this._username}.`
|
|
|
3213
4436
|
async resetUserData() {
|
|
3214
4437
|
if (this.syncStrategy.canAuthenticate()) {
|
|
3215
4438
|
return {
|
|
3216
|
-
status:
|
|
4439
|
+
status: import_common13.Status.error,
|
|
3217
4440
|
error: "Reset user data is only available for local-only mode. Use logout instead for remote sync."
|
|
3218
4441
|
};
|
|
3219
4442
|
}
|
|
@@ -3232,11 +4455,11 @@ Currently logged-in as ${this._username}.`
|
|
|
3232
4455
|
await localDB.bulkDocs(docsToDelete);
|
|
3233
4456
|
}
|
|
3234
4457
|
await this.init();
|
|
3235
|
-
return { status:
|
|
4458
|
+
return { status: import_common13.Status.ok };
|
|
3236
4459
|
} catch (error) {
|
|
3237
4460
|
logger.error("Failed to reset user data:", error);
|
|
3238
4461
|
return {
|
|
3239
|
-
status:
|
|
4462
|
+
status: import_common13.Status.error,
|
|
3240
4463
|
error: error instanceof Error ? error.message : "Unknown error during reset"
|
|
3241
4464
|
};
|
|
3242
4465
|
}
|
|
@@ -3963,6 +5186,19 @@ Currently logged-in as ${this._username}.`
|
|
|
3963
5186
|
};
|
|
3964
5187
|
await this.localDB.put(doc);
|
|
3965
5188
|
}
|
|
5189
|
+
async putUserOutcome(record) {
|
|
5190
|
+
try {
|
|
5191
|
+
await this.localDB.put(record);
|
|
5192
|
+
} catch (err) {
|
|
5193
|
+
if (err.status === 409) {
|
|
5194
|
+
const existing = await this.localDB.get(record._id);
|
|
5195
|
+
record._rev = existing._rev;
|
|
5196
|
+
await this.localDB.put(record);
|
|
5197
|
+
} else {
|
|
5198
|
+
throw err;
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
}
|
|
3966
5202
|
async deleteStrategyState(courseId, strategyKey) {
|
|
3967
5203
|
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
3968
5204
|
try {
|
|
@@ -4005,6 +5241,7 @@ var init_factory = __esm({
|
|
|
4005
5241
|
"use strict";
|
|
4006
5242
|
init_common();
|
|
4007
5243
|
init_logger();
|
|
5244
|
+
init_navigators();
|
|
4008
5245
|
NOT_SET = "NOT_SET";
|
|
4009
5246
|
ENV = {
|
|
4010
5247
|
COUCHDB_SERVER_PROTOCOL: NOT_SET,
|
|
@@ -4130,14 +5367,14 @@ var init_auth = __esm({
|
|
|
4130
5367
|
});
|
|
4131
5368
|
|
|
4132
5369
|
// src/impl/couch/CouchDBSyncStrategy.ts
|
|
4133
|
-
var
|
|
5370
|
+
var import_common15, log4, CouchDBSyncStrategy;
|
|
4134
5371
|
var init_CouchDBSyncStrategy = __esm({
|
|
4135
5372
|
"src/impl/couch/CouchDBSyncStrategy.ts"() {
|
|
4136
5373
|
"use strict";
|
|
4137
5374
|
init_factory();
|
|
4138
5375
|
init_types_legacy();
|
|
4139
5376
|
init_logger();
|
|
4140
|
-
|
|
5377
|
+
import_common15 = require("@vue-skuilder/common");
|
|
4141
5378
|
init_common();
|
|
4142
5379
|
init_pouchdb_setup();
|
|
4143
5380
|
init_couch();
|
|
@@ -4208,32 +5445,32 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
4208
5445
|
}
|
|
4209
5446
|
}
|
|
4210
5447
|
return {
|
|
4211
|
-
status:
|
|
5448
|
+
status: import_common15.Status.ok,
|
|
4212
5449
|
error: void 0
|
|
4213
5450
|
};
|
|
4214
5451
|
} else {
|
|
4215
5452
|
return {
|
|
4216
|
-
status:
|
|
5453
|
+
status: import_common15.Status.error,
|
|
4217
5454
|
error: "Failed to log in after account creation"
|
|
4218
5455
|
};
|
|
4219
5456
|
}
|
|
4220
5457
|
} else {
|
|
4221
5458
|
logger.warn(`Signup not OK: ${JSON.stringify(signupRequest)}`);
|
|
4222
5459
|
return {
|
|
4223
|
-
status:
|
|
5460
|
+
status: import_common15.Status.error,
|
|
4224
5461
|
error: "Account creation failed"
|
|
4225
5462
|
};
|
|
4226
5463
|
}
|
|
4227
5464
|
} catch (e) {
|
|
4228
5465
|
if (e.reason === "Document update conflict.") {
|
|
4229
5466
|
return {
|
|
4230
|
-
status:
|
|
5467
|
+
status: import_common15.Status.error,
|
|
4231
5468
|
error: "This username is taken!"
|
|
4232
5469
|
};
|
|
4233
5470
|
}
|
|
4234
5471
|
logger.error(`Error on signup: ${JSON.stringify(e)}`);
|
|
4235
5472
|
return {
|
|
4236
|
-
status:
|
|
5473
|
+
status: import_common15.Status.error,
|
|
4237
5474
|
error: e.message || "Unknown error during account creation"
|
|
4238
5475
|
};
|
|
4239
5476
|
}
|