@vue-skuilder/db 0.1.20 → 0.1.21

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