@vue-skuilder/db 0.1.32-c → 0.1.32-e

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 (55) hide show
  1. package/dist/{dataLayerProvider-BAn-LRh5.d.ts → contentSource-BMlMwSiG.d.cts} +202 -626
  2. package/dist/{dataLayerProvider-BJqBlMIl.d.cts → contentSource-Ht3N2f-y.d.ts} +202 -626
  3. package/dist/core/index.d.cts +23 -84
  4. package/dist/core/index.d.ts +23 -84
  5. package/dist/core/index.js +476 -1819
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +456 -1803
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/dataLayerProvider-BEqB8VBR.d.cts +67 -0
  10. package/dist/dataLayerProvider-DObSXjnf.d.ts +67 -0
  11. package/dist/impl/couch/index.d.cts +5 -5
  12. package/dist/impl/couch/index.d.ts +5 -5
  13. package/dist/impl/couch/index.js +484 -1827
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +460 -1807
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +5 -4
  18. package/dist/impl/static/index.d.ts +5 -4
  19. package/dist/impl/static/index.js +458 -1801
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +437 -1784
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-X6wHrURm.d.ts → index-BWvO-_rJ.d.ts} +1 -1
  24. package/dist/{index-m8MMGxxR.d.cts → index-Ba7hYbHj.d.cts} +1 -1
  25. package/dist/index.d.cts +461 -11
  26. package/dist/index.d.ts +461 -11
  27. package/dist/index.js +9239 -9159
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +9129 -9049
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-DZ5dUqbL.d.ts → types-CJrLM1Ew.d.ts} +1 -1
  32. package/dist/{types-ZL8tOPQZ.d.cts → types-W8n-B6HG.d.cts} +1 -1
  33. package/dist/{types-legacy-C7r0T4OV.d.cts → types-legacy-JXDxinpU.d.cts} +1 -1
  34. package/dist/{types-legacy-C7r0T4OV.d.ts → types-legacy-JXDxinpU.d.ts} +1 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/package.json +2 -2
  38. package/src/core/interfaces/contentSource.ts +2 -3
  39. package/src/core/navigators/Pipeline.ts +60 -6
  40. package/src/core/navigators/PipelineDebugger.ts +103 -0
  41. package/src/core/navigators/filters/hierarchyDefinition.ts +2 -1
  42. package/src/core/navigators/filters/interferenceMitigator.ts +2 -1
  43. package/src/core/navigators/filters/relativePriority.ts +2 -1
  44. package/src/core/navigators/filters/userTagPreference.ts +2 -1
  45. package/src/core/navigators/generators/CompositeGenerator.ts +58 -5
  46. package/src/core/navigators/generators/elo.ts +7 -7
  47. package/src/core/navigators/generators/prescribed.ts +124 -35
  48. package/src/core/navigators/generators/srs.ts +3 -4
  49. package/src/core/navigators/generators/types.ts +48 -2
  50. package/src/core/navigators/index.ts +3 -3
  51. package/src/impl/couch/classroomDB.ts +4 -3
  52. package/src/impl/couch/courseDB.ts +3 -3
  53. package/src/impl/static/courseDB.ts +3 -3
  54. package/src/study/SessionController.ts +5 -27
  55. package/src/study/TagFilteredContentSource.ts +4 -3
@@ -1,5 +1,5 @@
1
1
  import { CourseConfig } from '@vue-skuilder/common';
2
- import { D as DocType } from './types-legacy-C7r0T4OV.js';
2
+ import { D as DocType } from './types-legacy-JXDxinpU.js';
3
3
 
4
4
  interface StaticCourseManifest {
5
5
  version: string;
@@ -1,5 +1,5 @@
1
1
  import { CourseConfig } from '@vue-skuilder/common';
2
- import { D as DocType } from './types-legacy-C7r0T4OV.cjs';
2
+ import { D as DocType } from './types-legacy-JXDxinpU.cjs';
3
3
 
4
4
  interface StaticCourseManifest {
5
5
  version: string;
@@ -157,4 +157,4 @@ interface QuestionRecord extends CardRecord, Evaluation {
157
157
  priorAttemps: number;
158
158
  }
159
159
 
160
- export { type CardData as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, type CourseListData as b, type DisplayableData as c, type DataShapeData as d, type QuestionData as e, DocTypePrefixes as f, type CardHistory as g, type CardRecord as h, type QuestionRecord as i, log as l };
160
+ export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type CardData as d, type CourseListData as e, type DisplayableData as f, type DataShapeData as g, type QuestionData as h, type QuestionRecord as i, log as l };
@@ -157,4 +157,4 @@ interface QuestionRecord extends CardRecord, Evaluation {
157
157
  priorAttemps: number;
158
158
  }
159
159
 
160
- export { type CardData as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, type CourseListData as b, type DisplayableData as c, type DataShapeData as d, type QuestionData as e, DocTypePrefixes as f, type CardHistory as g, type CardRecord as h, type QuestionRecord as i, log as l };
160
+ export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type CardData as d, type CourseListData as e, type DisplayableData as f, type DataShapeData as g, type QuestionData as h, type QuestionRecord as i, log as l };
@@ -1,5 +1,5 @@
1
- export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-ZL8tOPQZ.cjs';
2
- export { C as CouchDBToStaticPacker } from '../../index-m8MMGxxR.cjs';
1
+ export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-W8n-B6HG.cjs';
2
+ export { C as CouchDBToStaticPacker } from '../../index-Ba7hYbHj.cjs';
3
3
  import '@vue-skuilder/common';
4
- import '../../types-legacy-C7r0T4OV.cjs';
4
+ import '../../types-legacy-JXDxinpU.cjs';
5
5
  import 'moment';
@@ -1,5 +1,5 @@
1
- export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-DZ5dUqbL.js';
2
- export { C as CouchDBToStaticPacker } from '../../index-X6wHrURm.js';
1
+ export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-CJrLM1Ew.js';
2
+ export { C as CouchDBToStaticPacker } from '../../index-BWvO-_rJ.js';
3
3
  import '@vue-skuilder/common';
4
- import '../../types-legacy-C7r0T4OV.js';
4
+ import '../../types-legacy-JXDxinpU.js';
5
5
  import 'moment';
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.32-c",
7
+ "version": "0.1.32-e",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.1.32-c",
51
+ "@vue-skuilder/common": "0.1.32-e",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -1,11 +1,10 @@
1
1
  import { getDataLayer } from '@db/factory';
2
2
  import { UserDBInterface } from '..';
3
3
  import { StudentClassroomDB } from '../../impl/couch/classroomDB';
4
- import { WeightedCard } from '../navigators';
4
+ import type { GeneratorResult, ReplanHints } from '../navigators/generators/types';
5
5
  import { TagFilter, hasActiveFilter } from '@vue-skuilder/common';
6
6
  import { TagFilteredContentSource } from '../../study/TagFilteredContentSource';
7
7
  import { OrchestrationContext } from '../orchestration';
8
- import type { ReplanHints } from '@db/study/SessionController';
9
8
 
10
9
  export type StudySessionFailedItem = StudySessionFailedNewItem | StudySessionFailedReviewItem;
11
10
 
@@ -73,7 +72,7 @@ export interface StudyContentSource {
73
72
  * @param limit - Maximum number of cards to return
74
73
  * @returns Cards sorted by score descending
75
74
  */
76
- getWeightedCards(limit: number): Promise<WeightedCard[]>;
75
+ getWeightedCards(limit: number): Promise<GeneratorResult>;
77
76
 
78
77
  /**
79
78
  * Get the orchestration context for this source.
@@ -5,6 +5,7 @@ import { ContentNavigator } from './index';
5
5
  import type { WeightedCard } from './index';
6
6
  import type { CardFilter, FilterContext } from './filters/types';
7
7
  import type { CardGenerator, GeneratorContext } from './generators/types';
8
+ import type { GeneratorResult } from './generators/types';
8
9
  import { logger } from '../../util/logger';
9
10
  import { createOrchestrationContext, OrchestrationContext } from '../orchestration';
10
11
  import { captureRun, buildRunReport, registerPipelineForDebug, type GeneratorSummary, type FilterImpact } from './PipelineDebugger';
@@ -22,9 +23,9 @@ import { captureRun, buildRunReport, registerPipelineForDebug, type GeneratorSum
22
23
  // 'gpc:exercise:t-*' — all t-variant exercises
23
24
  //
24
25
 
25
- // ReplanHints is the canonical type — re-export for consumers that import from Pipeline
26
- import { ReplanHints } from '@db/study/SessionController';
27
- export { ReplanHints };
26
+ // ReplanHints is defined in generators/types — re-export for consumers that import from Pipeline
27
+ import type { ReplanHints } from './generators/types';
28
+ export type { ReplanHints };
28
29
 
29
30
  /**
30
31
  * Convert a glob pattern (with `*` wildcards) to a RegExp.
@@ -47,6 +48,54 @@ function cardMatchesTagPattern(card: WeightedCard, pattern: string): boolean {
47
48
  return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
48
49
  }
49
50
 
51
+ function mergeHints(allHints: Array<ReplanHints | null | undefined>): ReplanHints | undefined {
52
+ const defined = allHints.filter((h): h is ReplanHints => h !== null && h !== undefined);
53
+ if (defined.length === 0) return undefined;
54
+
55
+ const merged: ReplanHints = {};
56
+
57
+ const boostTags: Record<string, number> = {};
58
+ for (const hints of defined) {
59
+ for (const [pattern, factor] of Object.entries(hints.boostTags ?? {})) {
60
+ boostTags[pattern] = (boostTags[pattern] ?? 1) * factor;
61
+ }
62
+ }
63
+ if (Object.keys(boostTags).length > 0) {
64
+ merged.boostTags = boostTags;
65
+ }
66
+
67
+ const boostCards: Record<string, number> = {};
68
+ for (const hints of defined) {
69
+ for (const [pattern, factor] of Object.entries(hints.boostCards ?? {})) {
70
+ boostCards[pattern] = (boostCards[pattern] ?? 1) * factor;
71
+ }
72
+ }
73
+ if (Object.keys(boostCards).length > 0) {
74
+ merged.boostCards = boostCards;
75
+ }
76
+
77
+ const concatUnique = (
78
+ field: 'requireTags' | 'requireCards' | 'excludeTags' | 'excludeCards'
79
+ ): void => {
80
+ const values = defined.flatMap((h) => h[field] ?? []);
81
+ if (values.length > 0) {
82
+ merged[field] = [...new Set(values)];
83
+ }
84
+ };
85
+
86
+ concatUnique('requireTags');
87
+ concatUnique('requireCards');
88
+ concatUnique('excludeTags');
89
+ concatUnique('excludeCards');
90
+
91
+ const labels = defined.map((h) => h._label).filter(Boolean);
92
+ if (labels.length > 0) {
93
+ merged._label = labels.join('; ');
94
+ }
95
+
96
+ return Object.keys(merged).length > 0 ? merged : undefined;
97
+ }
98
+
50
99
  // ============================================================================
51
100
  // PIPELINE LOGGING HELPERS
52
101
  // ============================================================================
@@ -293,7 +342,7 @@ export class Pipeline extends ContentNavigator {
293
342
  * @param limit - Maximum number of cards to return
294
343
  * @returns Cards sorted by score descending
295
344
  */
296
- async getWeightedCards(limit: number): Promise<WeightedCard[]> {
345
+ async getWeightedCards(limit: number): Promise<GeneratorResult> {
297
346
  const t0 = performance.now();
298
347
 
299
348
  // Build shared context once
@@ -311,9 +360,14 @@ export class Pipeline extends ContentNavigator {
311
360
  );
312
361
 
313
362
  // Get candidates from generator, passing context
314
- let cards = await this.generator.getWeightedCards(fetchLimit, context);
363
+ const generatorResult = await this.generator.getWeightedCards(fetchLimit, context);
364
+ let cards = generatorResult.cards;
315
365
  const tGenerate = performance.now();
316
366
  const generatedCount = cards.length;
367
+
368
+ // Merge generator-emitted hints with any externally supplied one-shot hints
369
+ const mergedHints = mergeHints([this._ephemeralHints, generatorResult.hints]);
370
+ this._ephemeralHints = mergedHints ?? null;
317
371
 
318
372
  // Capture generator breakdown for debugging (if CompositeGenerator)
319
373
  let generatorSummaries: GeneratorSummary[] | undefined;
@@ -436,7 +490,7 @@ export class Pipeline extends ContentNavigator {
436
490
  logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
437
491
  }
438
492
 
439
- return result;
493
+ return { cards: result };
440
494
  }
441
495
 
442
496
  /**
@@ -36,6 +36,7 @@ export function registerPipelineForDebug(pipeline: Pipeline): void {
36
36
  // window.skuilder.pipeline.showLastRun()
37
37
  // window.skuilder.pipeline.showCard('cardId123')
38
38
  // window.skuilder.pipeline.explainReviews()
39
+ // window.skuilder.pipeline.showPrescribed()
39
40
  // window.skuilder.pipeline.export()
40
41
  //
41
42
  // ============================================================================
@@ -389,6 +390,107 @@ export const pipelineDebugAPI = {
389
390
  console.groupEnd();
390
391
  },
391
392
 
393
+ /**
394
+ * Show prescribed-related cards from the most recent run.
395
+ *
396
+ * Highlights:
397
+ * - cards directly generated by the prescribed strategy
398
+ * - blocked prescribed targets mentioned in provenance
399
+ * - support tags resolved for blocked targets
400
+ *
401
+ * @param groupId - Optional prescribed group ID filter (e.g. 'intro-core')
402
+ */
403
+ showPrescribed(groupId?: string): void {
404
+ if (runHistory.length === 0) {
405
+ logger.info('[Pipeline Debug] No runs captured yet.');
406
+ return;
407
+ }
408
+
409
+ const run = runHistory[0];
410
+ const prescribedCards = run.cards.filter((c) =>
411
+ c.provenance.some((p) => p.strategy === 'prescribed')
412
+ );
413
+
414
+ // eslint-disable-next-line no-console
415
+ console.group(`🧭 Prescribed Debug (${run.courseId})`);
416
+
417
+ if (prescribedCards.length === 0) {
418
+ logger.info('No prescribed-generated cards were present in the most recent run.');
419
+ // eslint-disable-next-line no-console
420
+ console.groupEnd();
421
+ return;
422
+ }
423
+
424
+ const rows = prescribedCards
425
+ .map((card) => {
426
+ const prescribedProv = card.provenance.find((p) => p.strategy === 'prescribed');
427
+ const reason = prescribedProv?.reason ?? '';
428
+ const parsedGroup = reason.match(/group=([^;]+)/)?.[1] ?? 'unknown';
429
+ const mode = reason.match(/mode=([^;]+)/)?.[1] ?? 'unknown';
430
+ const blocked = reason.match(/blocked=([^;]+)/)?.[1] ?? 'unknown';
431
+ const blockedTargets = reason.match(/blockedTargets=([^;]+)/)?.[1] ?? 'none';
432
+ const supportTags = reason.match(/supportTags=([^;]+)/)?.[1] ?? 'none';
433
+ const multiplier = reason.match(/multiplier=([^;]+)/)?.[1] ?? 'unknown';
434
+
435
+ return {
436
+ group: parsedGroup,
437
+ mode,
438
+ cardId: card.cardId,
439
+ selected: card.selected ? 'yes' : 'no',
440
+ finalScore: card.finalScore.toFixed(3),
441
+ blocked,
442
+ blockedTargets,
443
+ supportTags,
444
+ multiplier,
445
+ };
446
+ })
447
+ .filter((row) => !groupId || row.group === groupId)
448
+ .sort((a, b) => Number(b.finalScore) - Number(a.finalScore));
449
+
450
+ if (rows.length === 0) {
451
+ logger.info(
452
+ `[Pipeline Debug] No prescribed cards matched group '${groupId}' in the most recent run.`
453
+ );
454
+ // eslint-disable-next-line no-console
455
+ console.groupEnd();
456
+ return;
457
+ }
458
+
459
+ // eslint-disable-next-line no-console
460
+ console.table(rows);
461
+
462
+ const selectedRows = rows.filter((r) => r.selected === 'yes');
463
+ const blockedTargetSet = new Set<string>();
464
+ const supportTagSet = new Set<string>();
465
+
466
+ for (const row of rows) {
467
+ if (row.blockedTargets && row.blockedTargets !== 'none') {
468
+ row.blockedTargets
469
+ .split('|')
470
+ .filter(Boolean)
471
+ .forEach((t) => blockedTargetSet.add(t));
472
+ }
473
+ if (row.supportTags && row.supportTags !== 'none') {
474
+ row.supportTags
475
+ .split('|')
476
+ .filter(Boolean)
477
+ .forEach((t) => supportTagSet.add(t));
478
+ }
479
+ }
480
+
481
+ logger.info(`Prescribed cards in run: ${rows.length}`);
482
+ logger.info(`Selected prescribed cards: ${selectedRows.length}`);
483
+ logger.info(
484
+ `Blocked prescribed targets referenced: ${blockedTargetSet.size > 0 ? [...blockedTargetSet].join(', ') : 'none'}`
485
+ );
486
+ logger.info(
487
+ `Resolved support tags referenced: ${supportTagSet.size > 0 ? [...supportTagSet].join(', ') : 'none'}`
488
+ );
489
+
490
+ // eslint-disable-next-line no-console
491
+ console.groupEnd();
492
+ },
493
+
392
494
  /**
393
495
  * Show all runs in compact format.
394
496
  */
@@ -555,6 +657,7 @@ Commands:
555
657
  .diagnoseCardSpace() Scan full card space through filters (async)
556
658
  .showRegistry() Show navigator registry (classes + roles)
557
659
  .showStrategies() Show registry + strategy mapping from last run
660
+ .showPrescribed(id?) Show prescribed-generated cards and blocked/support details from last run
558
661
  .listRuns() List all captured runs in table format
559
662
  .export() Export run history as JSON for bug reports
560
663
  .clear() Clear run history
@@ -4,6 +4,7 @@ import { ContentNavigator } from '../index';
4
4
  import type { WeightedCard } from '../index';
5
5
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
6
  import type { CardFilter, FilterContext } from './types';
7
+ import type { GeneratorResult } from '../generators/types';
7
8
  import { toCourseElo } from '@vue-skuilder/common';
8
9
  import { logger } from '../../../util/logger';
9
10
 
@@ -401,7 +402,7 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
401
402
  *
402
403
  * Use transform() via Pipeline instead.
403
404
  */
404
- async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
405
+ async getWeightedCards(_limit: number): Promise<GeneratorResult> {
405
406
  throw new Error(
406
407
  'HierarchyDefinitionNavigator is a filter and should not be used as a generator. ' +
407
408
  'Use Pipeline with a generator and this filter via transform().'
@@ -4,6 +4,7 @@ import { ContentNavigator } from '../index';
4
4
  import type { WeightedCard } from '../index';
5
5
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
6
  import type { CardFilter, FilterContext } from './types';
7
+ import type { GeneratorResult } from '../generators/types';
7
8
  import { toCourseElo } from '@vue-skuilder/common';
8
9
 
9
10
  /**
@@ -332,7 +333,7 @@ export default class InterferenceMitigatorNavigator extends ContentNavigator imp
332
333
  *
333
334
  * Use transform() via Pipeline instead.
334
335
  */
335
- async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
336
+ async getWeightedCards(_limit: number): Promise<GeneratorResult> {
336
337
  throw new Error(
337
338
  'InterferenceMitigatorNavigator is a filter and should not be used as a generator. ' +
338
339
  'Use Pipeline with a generator and this filter via transform().'
@@ -4,6 +4,7 @@ import { ContentNavigator } from '../index';
4
4
  import type { WeightedCard } from '../index';
5
5
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
6
  import type { CardFilter, FilterContext } from './types';
7
+ import type { GeneratorResult } from '../generators/types';
7
8
 
8
9
  /**
9
10
  * Configuration for the RelativePriority strategy.
@@ -238,7 +239,7 @@ export default class RelativePriorityNavigator extends ContentNavigator implemen
238
239
  *
239
240
  * Use transform() via Pipeline instead.
240
241
  */
241
- async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
242
+ async getWeightedCards(_limit: number): Promise<GeneratorResult> {
242
243
  throw new Error(
243
244
  'RelativePriorityNavigator is a filter and should not be used as a generator. ' +
244
245
  'Use Pipeline with a generator and this filter via transform().'
@@ -4,6 +4,7 @@ import { ContentNavigator } from '../index';
4
4
  import type { WeightedCard } from '../index';
5
5
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
6
  import type { CardFilter, FilterContext } from './types';
7
+ import type { GeneratorResult } from '../generators/types';
7
8
 
8
9
  // ============================================================================
9
10
  // USER TAG PREFERENCE FILTER
@@ -208,7 +209,7 @@ export default class UserTagPreferenceFilter extends ContentNavigator implements
208
209
  /**
209
210
  * Legacy getWeightedCards - throws as filters should not be used as generators.
210
211
  */
211
- async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
212
+ async getWeightedCards(_limit: number): Promise<GeneratorResult> {
212
213
  throw new Error(
213
214
  'UserTagPreferenceFilter is a filter and should not be used as a generator. ' +
214
215
  'Use Pipeline with a generator and this filter via transform().'
@@ -3,7 +3,7 @@ import type { WeightedCard } from '../index';
3
3
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
4
4
  import type { CourseDBInterface } from '../../interfaces/courseDB';
5
5
  import type { UserDBInterface } from '../../interfaces/userDB';
6
- import type { CardGenerator, GeneratorContext } from './types';
6
+ import type { CardGenerator, GeneratorContext, GeneratorResult, ReplanHints } from './types';
7
7
  import { logger } from '../../../util/logger';
8
8
 
9
9
  // ============================================================================
@@ -37,6 +37,54 @@ export enum AggregationMode {
37
37
  const DEFAULT_AGGREGATION_MODE = AggregationMode.FREQUENCY_BOOST;
38
38
  const FREQUENCY_BOOST_FACTOR = 0.1;
39
39
 
40
+ function mergeHints(allHints: Array<ReplanHints | undefined>): ReplanHints | undefined {
41
+ const defined = allHints.filter((h): h is ReplanHints => h !== undefined);
42
+ if (defined.length === 0) return undefined;
43
+
44
+ const merged: ReplanHints = {};
45
+
46
+ const boostTags: Record<string, number> = {};
47
+ for (const hints of defined) {
48
+ for (const [pattern, factor] of Object.entries(hints.boostTags ?? {})) {
49
+ boostTags[pattern] = (boostTags[pattern] ?? 1) * factor;
50
+ }
51
+ }
52
+ if (Object.keys(boostTags).length > 0) {
53
+ merged.boostTags = boostTags;
54
+ }
55
+
56
+ const boostCards: Record<string, number> = {};
57
+ for (const hints of defined) {
58
+ for (const [pattern, factor] of Object.entries(hints.boostCards ?? {})) {
59
+ boostCards[pattern] = (boostCards[pattern] ?? 1) * factor;
60
+ }
61
+ }
62
+ if (Object.keys(boostCards).length > 0) {
63
+ merged.boostCards = boostCards;
64
+ }
65
+
66
+ const concatUnique = (
67
+ field: 'requireTags' | 'requireCards' | 'excludeTags' | 'excludeCards'
68
+ ): void => {
69
+ const values = defined.flatMap((h) => h[field] ?? []);
70
+ if (values.length > 0) {
71
+ merged[field] = [...new Set(values)];
72
+ }
73
+ };
74
+
75
+ concatUnique('requireTags');
76
+ concatUnique('requireCards');
77
+ concatUnique('excludeTags');
78
+ concatUnique('excludeCards');
79
+
80
+ const labels = defined.map((h) => h._label).filter(Boolean);
81
+ if (labels.length > 0) {
82
+ merged._label = labels.join('; ');
83
+ }
84
+
85
+ return Object.keys(merged).length > 0 ? merged : undefined;
86
+ }
87
+
40
88
  /**
41
89
  * Composes multiple generators into a single generator.
42
90
  *
@@ -100,7 +148,7 @@ export default class CompositeGenerator extends ContentNavigator implements Card
100
148
  * @param limit - Maximum number of cards to return
101
149
  * @param context - GeneratorContext passed to child generators (required when called via Pipeline)
102
150
  */
103
- async getWeightedCards(limit: number, context?: GeneratorContext): Promise<WeightedCard[]> {
151
+ async getWeightedCards(limit: number, context?: GeneratorContext): Promise<GeneratorResult> {
104
152
  if (!context) {
105
153
  throw new Error(
106
154
  'CompositeGenerator.getWeightedCards requires a GeneratorContext. ' +
@@ -115,7 +163,8 @@ export default class CompositeGenerator extends ContentNavigator implements Card
115
163
 
116
164
  // Log per-generator breakdown for transparency
117
165
  const generatorSummaries: string[] = [];
118
- results.forEach((cards, index) => {
166
+ results.forEach((result, index) => {
167
+ const cards = result.cards;
119
168
  const gen = this.generators[index];
120
169
  const genName = gen.name || `Generator ${index}`;
121
170
  const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes('new card'));
@@ -138,7 +187,8 @@ export default class CompositeGenerator extends ContentNavigator implements Card
138
187
  type WeightedResult = { card: WeightedCard; weight: number };
139
188
  const byCardId = new Map<string, WeightedResult[]>();
140
189
 
141
- results.forEach((cards, index) => {
190
+ results.forEach((result, index) => {
191
+ const cards = result.cards;
142
192
  // Access learnable weight if available
143
193
  const gen = this.generators[index] as unknown as ContentNavigator;
144
194
 
@@ -205,7 +255,10 @@ export default class CompositeGenerator extends ContentNavigator implements Card
205
255
  }
206
256
 
207
257
  // Sort by score descending and limit
208
- return merged.sort((a, b) => b.score - a.score).slice(0, limit);
258
+ const cards = merged.sort((a, b) => b.score - a.score).slice(0, limit);
259
+ const hints = mergeHints(results.map((result) => result.hints));
260
+
261
+ return { cards, hints };
209
262
  }
210
263
 
211
264
  /**
@@ -4,7 +4,7 @@ import { ContentNavigator } from '../index';
4
4
  import type { WeightedCard } from '../index';
5
5
  import { toCourseElo } from '@vue-skuilder/common';
6
6
  import type { QualifiedCardID } from '../..';
7
- import type { CardGenerator, GeneratorContext } from './types';
7
+ import type { CardGenerator, GeneratorContext, GeneratorResult } from './types';
8
8
  import { logger } from '@db/util/logger';
9
9
 
10
10
  // ============================================================================
@@ -65,7 +65,7 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
65
65
  * @param limit - Maximum number of cards to return
66
66
  * @param context - Optional GeneratorContext (used when called via Pipeline)
67
67
  */
68
- async getWeightedCards(limit: number, context?: GeneratorContext): Promise<WeightedCard[]> {
68
+ async getWeightedCards(limit: number, context?: GeneratorContext): Promise<GeneratorResult> {
69
69
  // Determine user ELO - from context if available, otherwise fetch
70
70
  let userGlobalElo: number;
71
71
  if (context?.userElo !== undefined) {
@@ -125,18 +125,18 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
125
125
  // Sort by sampling key descending (weighted sample without replacement)
126
126
  scored.sort((a, b) => b.score - a.score);
127
127
 
128
- const result = scored.slice(0, limit);
128
+ const cards = scored.slice(0, limit);
129
129
 
130
130
  // Log summary for transparency
131
- if (result.length > 0) {
132
- const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(', ');
131
+ if (cards.length > 0) {
132
+ const topScores = cards.slice(0, 3).map((c) => c.score.toFixed(2)).join(', ');
133
133
  logger.info(
134
- `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
134
+ `[ELO] Course ${this.course.getCourseID()}: ${cards.length} new cards (top scores: ${topScores})`
135
135
  );
136
136
  } else {
137
137
  logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
138
138
  }
139
139
 
140
- return result;
140
+ return { cards };
141
141
  }
142
142
  }