@vue-skuilder/db 0.1.32-e → 0.1.33-vite8-4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/index.d.cts +20 -3
- package/dist/core/index.d.ts +20 -3
- package/dist/core/index.js +457 -28
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +457 -28
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +457 -28
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +457 -28
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +457 -28
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +457 -28
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.js +457 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +457 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/core/navigators/Pipeline.ts +104 -13
- package/src/core/navigators/PipelineDebugger.ts +296 -3
- package/src/core/navigators/generators/CompositeGenerator.ts +4 -1
- package/src/core/navigators/generators/prescribed.ts +246 -22
- package/tests/core/navigators/CompositeGenerator.test.ts +14 -50
- package/tests/core/navigators/Pipeline.test.ts +13 -12
|
@@ -3,9 +3,8 @@ import type { UserDBInterface } from '../../interfaces/userDB';
|
|
|
3
3
|
import { ContentNavigator } from '../index';
|
|
4
4
|
import type { WeightedCard } from '../index';
|
|
5
5
|
import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
|
|
6
|
-
import type { CardGenerator, GeneratorContext, GeneratorResult } from './types';
|
|
6
|
+
import type { CardGenerator, GeneratorContext, GeneratorResult, ReplanHints } from './types';
|
|
7
7
|
import { logger } from '@db/util/logger';
|
|
8
|
-
import type { ReplanHints } from '../../study/SessionController';
|
|
9
8
|
|
|
10
9
|
// ============================================================================
|
|
11
10
|
// PRESCRIBED CARDS GENERATOR
|
|
@@ -89,6 +88,7 @@ interface GroupRuntimeState {
|
|
|
89
88
|
surfaceableTargets: string[];
|
|
90
89
|
targetTags: Map<string, string[]>;
|
|
91
90
|
supportCandidates: string[];
|
|
91
|
+
discoveredSupportCandidates: string[];
|
|
92
92
|
supportTags: string[];
|
|
93
93
|
pressureMultiplier: number;
|
|
94
94
|
supportMultiplier: number;
|
|
@@ -108,11 +108,11 @@ const DEFAULT_HIERARCHY_DEPTH = 2;
|
|
|
108
108
|
const DEFAULT_MIN_COUNT = 3;
|
|
109
109
|
const BASE_TARGET_SCORE = 1.0;
|
|
110
110
|
const BASE_SUPPORT_SCORE = 0.8;
|
|
111
|
+
const DISCOVERED_SUPPORT_SCORE = 12.0;
|
|
111
112
|
const MAX_TARGET_MULTIPLIER = 8.0;
|
|
112
113
|
const MAX_SUPPORT_MULTIPLIER = 4.0;
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const PRESCRIBED_DEBUG_VERSION = 'testversion-prescribed-v2';
|
|
114
|
+
|
|
115
|
+
const PRESCRIBED_DEBUG_VERSION = 'testversion-prescribed-v3';
|
|
116
116
|
|
|
117
117
|
function dedupe<T>(arr: T[]): T[] {
|
|
118
118
|
return [...new Set(arr)];
|
|
@@ -135,6 +135,31 @@ function matchesTagPattern(tag: string, pattern: string): boolean {
|
|
|
135
135
|
return re.test(tag);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Extract the word stem from a card ID for deduplication.
|
|
140
|
+
* ML: c-ml-{word}-{blanks} → {word}
|
|
141
|
+
* WS: c-ws-{word}-{contrast} → {word}
|
|
142
|
+
* Other: full cardId as fallback.
|
|
143
|
+
*/
|
|
144
|
+
function extractWordStem(cardId: string): string {
|
|
145
|
+
for (const prefix of ['c-ml-', 'c-ws-', 'c-spelling-']) {
|
|
146
|
+
if (cardId.startsWith(prefix)) {
|
|
147
|
+
const rest = cardId.slice(prefix.length);
|
|
148
|
+
const lastDash = rest.lastIndexOf('-');
|
|
149
|
+
return lastDash > 0 ? rest.slice(0, lastDash) : rest;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return cardId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Fisher-Yates shuffle in place. */
|
|
156
|
+
function shuffleInPlace<T>(arr: T[]): void {
|
|
157
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
158
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
159
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
function pickTopByScore(cards: WeightedCard[], limit: number): WeightedCard[] {
|
|
139
164
|
return [...cards]
|
|
140
165
|
.sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId))
|
|
@@ -201,6 +226,22 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
201
226
|
? await this.course.getAppliedTagsBatch(allRelevantIds)
|
|
202
227
|
: new Map<string, string[]>();
|
|
203
228
|
|
|
229
|
+
const courseTagDocs = await this.course.getCourseTagStubs().catch(
|
|
230
|
+
() =>
|
|
231
|
+
({
|
|
232
|
+
rows: [],
|
|
233
|
+
offset: 0,
|
|
234
|
+
total_rows: 0,
|
|
235
|
+
}) as unknown as Awaited<ReturnType<CourseDBInterface['getCourseTagStubs']>>
|
|
236
|
+
);
|
|
237
|
+
const cardsByTag = new Map<string, string[]>();
|
|
238
|
+
for (const row of courseTagDocs.rows ?? []) {
|
|
239
|
+
const tagDoc = row.doc as { name?: string; taggedCards?: string[] } | undefined;
|
|
240
|
+
if (tagDoc?.name && Array.isArray(tagDoc.taggedCards)) {
|
|
241
|
+
cardsByTag.set(tagDoc.name, [...tagDoc.taggedCards]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
204
245
|
const nextState: PrescribedProgressState = {
|
|
205
246
|
updatedAt: isoNow(),
|
|
206
247
|
groups: {},
|
|
@@ -217,12 +258,46 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
217
258
|
activeIds,
|
|
218
259
|
seenIds,
|
|
219
260
|
tagsByCard,
|
|
261
|
+
cardsByTag,
|
|
220
262
|
hierarchyConfigs,
|
|
221
263
|
userTagElo,
|
|
222
264
|
userGlobalElo,
|
|
223
265
|
});
|
|
224
266
|
|
|
225
267
|
groupRuntimes.push(runtime);
|
|
268
|
+
|
|
269
|
+
logger.info(
|
|
270
|
+
`[Prescribed] Group '${group.id}': ` +
|
|
271
|
+
`${group.targetCardIds.length} targets total, ` +
|
|
272
|
+
`${runtime.encounteredTargets.size} encountered, ` +
|
|
273
|
+
`${runtime.pendingTargets.length} pending ` +
|
|
274
|
+
`(${runtime.surfaceableTargets.length} surfaceable, ${runtime.blockedTargets.length} blocked), ` +
|
|
275
|
+
`${runtime.supportCandidates.length} authored support candidates, ` +
|
|
276
|
+
`${runtime.discoveredSupportCandidates.length} discovered support candidates, ` +
|
|
277
|
+
`pressure=${runtime.pressureMultiplier.toFixed(2)}`
|
|
278
|
+
);
|
|
279
|
+
if (runtime.blockedTargets.length > 0) {
|
|
280
|
+
logger.info(
|
|
281
|
+
`[Prescribed] Group '${group.id}' blocked targets: ${runtime.blockedTargets.join(', ')}`
|
|
282
|
+
);
|
|
283
|
+
logger.info(
|
|
284
|
+
`[Prescribed] Group '${group.id}' support tags needed: ${runtime.supportTags.join(', ') || '(none)'}`
|
|
285
|
+
);
|
|
286
|
+
logger.info(
|
|
287
|
+
`[Prescribed] Group '${group.id}' escalation mode: ` +
|
|
288
|
+
(runtime.supportCandidates.length > 0
|
|
289
|
+
? 'direct-support'
|
|
290
|
+
: runtime.discoveredSupportCandidates.length > 0
|
|
291
|
+
? 'inserted-support-candidates'
|
|
292
|
+
: 'boost-only')
|
|
293
|
+
);
|
|
294
|
+
if (runtime.discoveredSupportCandidates.length > 0) {
|
|
295
|
+
logger.info(
|
|
296
|
+
`[Prescribed] Group '${group.id}' discovered support candidates: ${runtime.discoveredSupportCandidates.join(', ')}`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
226
301
|
nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
|
|
227
302
|
|
|
228
303
|
const directCards = this.buildDirectTargetCards(
|
|
@@ -235,8 +310,13 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
235
310
|
courseId,
|
|
236
311
|
emittedIds
|
|
237
312
|
);
|
|
313
|
+
const discoveredSupportCards = this.buildDiscoveredSupportCards(
|
|
314
|
+
runtime,
|
|
315
|
+
courseId,
|
|
316
|
+
emittedIds
|
|
317
|
+
);
|
|
238
318
|
|
|
239
|
-
emitted.push(...directCards, ...supportCards);
|
|
319
|
+
emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
|
|
240
320
|
}
|
|
241
321
|
|
|
242
322
|
const hintSummary = this.buildSupportHintSummary(groupRuntimes);
|
|
@@ -251,8 +331,21 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
251
331
|
}
|
|
252
332
|
: undefined;
|
|
253
333
|
|
|
334
|
+
if (hints) {
|
|
335
|
+
const tagEntries = Object.entries(hints.boostTags ?? {}) as Array<[string, number]>;
|
|
336
|
+
logger.info(
|
|
337
|
+
`[Prescribed] Emitting ${tagEntries.length} boost hint(s): ` +
|
|
338
|
+
tagEntries.map(([tag, mult]) => `${tag}×${mult.toFixed(1)}`).join(', ')
|
|
339
|
+
);
|
|
340
|
+
} else {
|
|
341
|
+
logger.info('[Prescribed] No hints to emit (no blocked targets or no support tags)');
|
|
342
|
+
}
|
|
343
|
+
|
|
254
344
|
if (emitted.length === 0) {
|
|
255
|
-
logger.
|
|
345
|
+
logger.info(
|
|
346
|
+
'[Prescribed] 0 cards emitted (all targets blocked, authored/discovered support candidates exhausted)' +
|
|
347
|
+
(hints ? ' — boost hints emitted but may not survive filters' : '')
|
|
348
|
+
);
|
|
256
349
|
await this.putStrategyState(nextState).catch((e) => {
|
|
257
350
|
logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
|
|
258
351
|
});
|
|
@@ -292,7 +385,8 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
292
385
|
logger.info(
|
|
293
386
|
`[Prescribed] Emitting ${finalCards.length} cards ` +
|
|
294
387
|
`(${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=target')).length} target, ` +
|
|
295
|
-
`${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=support')).length} support
|
|
388
|
+
`${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=support')).length} support, ` +
|
|
389
|
+
`${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=discovered-support')).length} discovered support)`
|
|
296
390
|
);
|
|
297
391
|
|
|
298
392
|
return hints ? { cards: finalCards, hints } : { cards: finalCards };
|
|
@@ -325,14 +419,22 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
325
419
|
|
|
326
420
|
private parseConfig(serializedData: string): PrescribedConfig {
|
|
327
421
|
try {
|
|
328
|
-
const parsed = JSON.parse(serializedData);
|
|
422
|
+
const parsed = JSON.parse(serializedData) as { groups?: unknown[] };
|
|
329
423
|
const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
|
|
330
424
|
const groups: PrescribedGroupConfig[] = groupsRaw
|
|
331
|
-
.map((raw: any, i: number) => ({
|
|
425
|
+
.map((raw: any, i: number): PrescribedGroupConfig => ({
|
|
332
426
|
id: typeof raw.id === 'string' && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
|
|
333
|
-
targetCardIds: dedupe(
|
|
334
|
-
|
|
335
|
-
|
|
427
|
+
targetCardIds: dedupe(
|
|
428
|
+
(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v: unknown): v is string => typeof v === 'string') : [])
|
|
429
|
+
),
|
|
430
|
+
supportCardIds: dedupe(
|
|
431
|
+
(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v: unknown): v is string => typeof v === 'string') : [])
|
|
432
|
+
),
|
|
433
|
+
supportTagPatterns: dedupe(
|
|
434
|
+
(Array.isArray(raw.supportTagPatterns)
|
|
435
|
+
? raw.supportTagPatterns.filter((v: unknown): v is string => typeof v === 'string')
|
|
436
|
+
: [])
|
|
437
|
+
),
|
|
336
438
|
freshnessWindowSessions:
|
|
337
439
|
typeof raw.freshnessWindowSessions === 'number' ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
|
|
338
440
|
maxDirectTargetsPerRun:
|
|
@@ -358,12 +460,12 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
358
460
|
|
|
359
461
|
private async loadHierarchyConfigs(): Promise<HierarchyConfig[]> {
|
|
360
462
|
try {
|
|
361
|
-
const strategies = await this.course.
|
|
463
|
+
const strategies = await this.course.getAllNavigationStrategies();
|
|
362
464
|
return strategies
|
|
363
|
-
.filter((s) => s.implementingClass === 'hierarchyDefinition')
|
|
364
|
-
.map((s) => {
|
|
465
|
+
.filter((s: ContentNavigationStrategyData) => s.implementingClass === 'hierarchyDefinition')
|
|
466
|
+
.map((s: ContentNavigationStrategyData) => {
|
|
365
467
|
try {
|
|
366
|
-
const parsed = JSON.parse(s.serializedData);
|
|
468
|
+
const parsed = JSON.parse(s.serializedData) as { prerequisites?: Record<string, TagPrerequisite[]> };
|
|
367
469
|
return {
|
|
368
470
|
prerequisites: parsed.prerequisites || {},
|
|
369
471
|
} as HierarchyConfig;
|
|
@@ -383,6 +485,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
383
485
|
activeIds: Set<string>;
|
|
384
486
|
seenIds: Set<string>;
|
|
385
487
|
tagsByCard: Map<string, string[]>;
|
|
488
|
+
cardsByTag: Map<string, string[]>;
|
|
386
489
|
hierarchyConfigs: HierarchyConfig[];
|
|
387
490
|
userTagElo: Record<string, { score: number; count: number }>;
|
|
388
491
|
userGlobalElo: number;
|
|
@@ -393,6 +496,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
393
496
|
activeIds,
|
|
394
497
|
seenIds,
|
|
395
498
|
tagsByCard,
|
|
499
|
+
cardsByTag,
|
|
396
500
|
hierarchyConfigs,
|
|
397
501
|
userTagElo,
|
|
398
502
|
userGlobalElo,
|
|
@@ -468,6 +572,28 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
468
572
|
),
|
|
469
573
|
]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
|
|
470
574
|
|
|
575
|
+
const discoveredSupportCandidates =
|
|
576
|
+
blockedTargets.length > 0 && supportTags.size > 0 && supportCandidates.length === 0
|
|
577
|
+
? this.findDiscoveredSupportCards({
|
|
578
|
+
supportTags: [...supportTags],
|
|
579
|
+
cardsByTag,
|
|
580
|
+
activeIds,
|
|
581
|
+
seenIds,
|
|
582
|
+
excludedIds: new Set([
|
|
583
|
+
...group.targetCardIds,
|
|
584
|
+
...(group.supportCardIds ?? []),
|
|
585
|
+
]),
|
|
586
|
+
limit: group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN,
|
|
587
|
+
})
|
|
588
|
+
: [];
|
|
589
|
+
|
|
590
|
+
if (blockedTargets.length > 0 && supportTags.size > 0 && discoveredSupportCandidates.length === 0) {
|
|
591
|
+
logger.info(
|
|
592
|
+
`[Prescribed] Group '${group.id}' discovered 0 broader support candidates ` +
|
|
593
|
+
`(blocked=${blockedTargets.length}; authoredSupport=${supportCandidates.length})`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
471
597
|
const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
|
|
472
598
|
const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
|
|
473
599
|
const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
|
|
@@ -488,6 +614,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
488
614
|
surfaceableTargets,
|
|
489
615
|
targetTags,
|
|
490
616
|
supportCandidates,
|
|
617
|
+
discoveredSupportCandidates,
|
|
491
618
|
supportTags: [...supportTags],
|
|
492
619
|
pressureMultiplier,
|
|
493
620
|
supportMultiplier,
|
|
@@ -594,6 +721,50 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
594
721
|
return cards;
|
|
595
722
|
}
|
|
596
723
|
|
|
724
|
+
private buildDiscoveredSupportCards(
|
|
725
|
+
runtime: GroupRuntimeState,
|
|
726
|
+
courseId: string,
|
|
727
|
+
emittedIds: Set<string>
|
|
728
|
+
): WeightedCard[] {
|
|
729
|
+
if (runtime.blockedTargets.length === 0 || runtime.discoveredSupportCandidates.length === 0) {
|
|
730
|
+
return [];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
|
|
734
|
+
const supportIds = runtime.discoveredSupportCandidates
|
|
735
|
+
.filter((id) => !emittedIds.has(id))
|
|
736
|
+
.slice(0, maxSupport);
|
|
737
|
+
|
|
738
|
+
const cards: WeightedCard[] = [];
|
|
739
|
+
for (const cardId of supportIds) {
|
|
740
|
+
emittedIds.add(cardId);
|
|
741
|
+
cards.push({
|
|
742
|
+
cardId,
|
|
743
|
+
courseId,
|
|
744
|
+
score: DISCOVERED_SUPPORT_SCORE * runtime.supportMultiplier,
|
|
745
|
+
provenance: [
|
|
746
|
+
{
|
|
747
|
+
strategy: 'prescribed',
|
|
748
|
+
strategyName: this.strategyName || this.name,
|
|
749
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
|
|
750
|
+
action: 'generated' as const,
|
|
751
|
+
score: DISCOVERED_SUPPORT_SCORE * runtime.supportMultiplier,
|
|
752
|
+
reason:
|
|
753
|
+
`mode=discovered-support;group=${runtime.group.id};pending=${runtime.pendingTargets.length};` +
|
|
754
|
+
`blocked=${runtime.blockedTargets.length};` +
|
|
755
|
+
`blockedTargets=${runtime.blockedTargets.join('|') || 'none'};` +
|
|
756
|
+
`supportCard=${cardId};` +
|
|
757
|
+
`supportTags=${runtime.supportTags.join('|') || 'none'};` +
|
|
758
|
+
`multiplier=${runtime.supportMultiplier.toFixed(2)};` +
|
|
759
|
+
`testversion=${runtime.debugVersion}`,
|
|
760
|
+
},
|
|
761
|
+
],
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return cards;
|
|
766
|
+
}
|
|
767
|
+
|
|
597
768
|
private findSupportCardsByTags(
|
|
598
769
|
group: PrescribedGroupConfig,
|
|
599
770
|
tagsByCard: Map<string, string[]>,
|
|
@@ -626,6 +797,63 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
626
797
|
return [...candidates];
|
|
627
798
|
}
|
|
628
799
|
|
|
800
|
+
private findDiscoveredSupportCards(args: {
|
|
801
|
+
supportTags: string[];
|
|
802
|
+
cardsByTag: Map<string, string[]>;
|
|
803
|
+
activeIds: Set<string>;
|
|
804
|
+
seenIds: Set<string>;
|
|
805
|
+
excludedIds: Set<string>;
|
|
806
|
+
limit: number;
|
|
807
|
+
}): string[] {
|
|
808
|
+
const { supportTags, cardsByTag, activeIds, seenIds, excludedIds, limit } = args;
|
|
809
|
+
|
|
810
|
+
const byCardId = new Map<string, { cardId: string; matches: number }>();
|
|
811
|
+
|
|
812
|
+
for (const supportTag of supportTags) {
|
|
813
|
+
const taggedCards = cardsByTag.get(supportTag) ?? [];
|
|
814
|
+
for (const cardId of taggedCards) {
|
|
815
|
+
if (activeIds.has(cardId) || seenIds.has(cardId) || excludedIds.has(cardId)) {
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const existing = byCardId.get(cardId);
|
|
819
|
+
if (existing) {
|
|
820
|
+
existing.matches += 1;
|
|
821
|
+
} else {
|
|
822
|
+
byCardId.set(cardId, { cardId, matches: 1 });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const candidates = [...byCardId.values()]
|
|
828
|
+
.sort((a, b) => b.matches - a.matches || a.cardId.localeCompare(b.cardId));
|
|
829
|
+
|
|
830
|
+
// Diversify by word stem — avoid returning 4 variants of "year".
|
|
831
|
+
// ML cards follow c-ml-{word}-{blanks}, so the stem is everything before
|
|
832
|
+
// the last dash-delimited segment of digits/commas.
|
|
833
|
+
const usedStems = new Set<string>();
|
|
834
|
+
const diverse: typeof candidates = [];
|
|
835
|
+
const deferred: typeof candidates = [];
|
|
836
|
+
|
|
837
|
+
for (const entry of candidates) {
|
|
838
|
+
const stem = extractWordStem(entry.cardId);
|
|
839
|
+
if (!usedStems.has(stem)) {
|
|
840
|
+
usedStems.add(stem);
|
|
841
|
+
diverse.push(entry);
|
|
842
|
+
} else {
|
|
843
|
+
deferred.push(entry);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Combine diverse-first, then deferred for overflow, and shuffle within
|
|
848
|
+
// each tier so we don't always pick the same card for a given word.
|
|
849
|
+
shuffleInPlace(diverse);
|
|
850
|
+
shuffleInPlace(deferred);
|
|
851
|
+
|
|
852
|
+
return [...diverse, ...deferred]
|
|
853
|
+
.slice(0, limit)
|
|
854
|
+
.map((entry) => entry.cardId);
|
|
855
|
+
}
|
|
856
|
+
|
|
629
857
|
private resolveBlockedSupportTags(
|
|
630
858
|
targetTags: string[],
|
|
631
859
|
hierarchyConfigs: HierarchyConfig[],
|
|
@@ -697,7 +925,6 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
697
925
|
out: Set<string>
|
|
698
926
|
): void {
|
|
699
927
|
if (depth < 0 || visited.has(tag)) return;
|
|
700
|
-
if (this.isHardGatedTag(tag)) return;
|
|
701
928
|
|
|
702
929
|
visited.add(tag);
|
|
703
930
|
|
|
@@ -732,10 +959,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
732
959
|
}
|
|
733
960
|
}
|
|
734
961
|
|
|
735
|
-
|
|
736
|
-
return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) &&
|
|
737
|
-
tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
|
|
738
|
-
}
|
|
962
|
+
|
|
739
963
|
|
|
740
964
|
private isPrerequisiteMet(
|
|
741
965
|
prereq: TagPrerequisite,
|
|
@@ -3,7 +3,7 @@ import CompositeGenerator, {
|
|
|
3
3
|
AggregationMode,
|
|
4
4
|
} from '../../../src/core/navigators/generators/CompositeGenerator';
|
|
5
5
|
import { ContentNavigator, WeightedCard } from '../../../src/core/navigators/index';
|
|
6
|
-
import { GeneratorContext } from '../../../src/core/navigators/generators/types';
|
|
6
|
+
import { GeneratorContext, GeneratorResult } from '../../../src/core/navigators/generators/types';
|
|
7
7
|
import { UserDBInterface } from '../../../src/core/interfaces/userDB';
|
|
8
8
|
import { CourseDBInterface } from '../../../src/core/interfaces/courseDB';
|
|
9
9
|
|
|
@@ -41,8 +41,8 @@ class MockGenerator extends ContentNavigator {
|
|
|
41
41
|
this.mockWeightedCards = cards;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
async getWeightedCards(limit: number): Promise<
|
|
45
|
-
return this.mockWeightedCards.slice(0, limit);
|
|
44
|
+
async getWeightedCards(limit: number): Promise<GeneratorResult> {
|
|
45
|
+
return { cards: this.mockWeightedCards.slice(0, limit) };
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -96,7 +96,7 @@ describe('CompositeGenerator', () => {
|
|
|
96
96
|
]);
|
|
97
97
|
|
|
98
98
|
const composite = new CompositeGenerator([generator]);
|
|
99
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
99
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
100
100
|
|
|
101
101
|
expect(result).toHaveLength(2);
|
|
102
102
|
expect(result[0].cardId).toBe('card-1');
|
|
@@ -114,7 +114,7 @@ describe('CompositeGenerator', () => {
|
|
|
114
114
|
]);
|
|
115
115
|
|
|
116
116
|
const composite = new CompositeGenerator([generator]);
|
|
117
|
-
const result = await composite.getWeightedCards(2, mockContext);
|
|
117
|
+
const { cards: result } = await composite.getWeightedCards(2, mockContext);
|
|
118
118
|
|
|
119
119
|
expect(result).toHaveLength(2);
|
|
120
120
|
expect(result[0].cardId).toBe('card-1');
|
|
@@ -137,7 +137,7 @@ describe('CompositeGenerator', () => {
|
|
|
137
137
|
]);
|
|
138
138
|
|
|
139
139
|
const composite = new CompositeGenerator([gen1, gen2], AggregationMode.AVERAGE);
|
|
140
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
140
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
141
141
|
|
|
142
142
|
// Should have 3 unique cards
|
|
143
143
|
expect(result).toHaveLength(3);
|
|
@@ -157,7 +157,7 @@ describe('CompositeGenerator', () => {
|
|
|
157
157
|
gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.9, 'new')]);
|
|
158
158
|
|
|
159
159
|
const composite = new CompositeGenerator([gen1, gen2], AggregationMode.MAX);
|
|
160
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
160
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
161
161
|
|
|
162
162
|
expect(result).toHaveLength(1);
|
|
163
163
|
expect(result[0].score).toBe(0.9);
|
|
@@ -173,7 +173,7 @@ describe('CompositeGenerator', () => {
|
|
|
173
173
|
gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.6, 'new')]);
|
|
174
174
|
|
|
175
175
|
const composite = new CompositeGenerator([gen1, gen2], AggregationMode.AVERAGE);
|
|
176
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
176
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
177
177
|
|
|
178
178
|
expect(result).toHaveLength(1);
|
|
179
179
|
expect(result[0].score).toBeCloseTo(0.7); // (0.8 + 0.6) / 2
|
|
@@ -190,7 +190,7 @@ describe('CompositeGenerator', () => {
|
|
|
190
190
|
gen3.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.6, 'new')]);
|
|
191
191
|
|
|
192
192
|
const composite = new CompositeGenerator([gen1, gen2, gen3], AggregationMode.AVERAGE);
|
|
193
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
193
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
194
194
|
|
|
195
195
|
expect(result).toHaveLength(1);
|
|
196
196
|
expect(result[0].score).toBeCloseTo(0.7); // (0.9 + 0.6 + 0.6) / 3
|
|
@@ -206,7 +206,7 @@ describe('CompositeGenerator', () => {
|
|
|
206
206
|
gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.6, 'new')]);
|
|
207
207
|
|
|
208
208
|
const composite = new CompositeGenerator([gen1, gen2]); // default mode
|
|
209
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
209
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
210
210
|
|
|
211
211
|
expect(result).toHaveLength(1);
|
|
212
212
|
// avg = (0.8 + 0.6) / 2 = 0.7
|
|
@@ -226,7 +226,7 @@ describe('CompositeGenerator', () => {
|
|
|
226
226
|
gen3.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.6, 'new')]);
|
|
227
227
|
|
|
228
228
|
const composite = new CompositeGenerator([gen1, gen2, gen3]);
|
|
229
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
229
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
230
230
|
|
|
231
231
|
expect(result).toHaveLength(1);
|
|
232
232
|
// avg = 0.6
|
|
@@ -243,7 +243,7 @@ describe('CompositeGenerator', () => {
|
|
|
243
243
|
gen2.setWeightedCards([makeWeightedCard('card-2', 'course-1', 0.6, 'new')]);
|
|
244
244
|
|
|
245
245
|
const composite = new CompositeGenerator([gen1, gen2]);
|
|
246
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
246
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
247
247
|
|
|
248
248
|
expect(result).toHaveLength(2);
|
|
249
249
|
// No boost for single-generator cards
|
|
@@ -254,42 +254,6 @@ describe('CompositeGenerator', () => {
|
|
|
254
254
|
});
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
-
describe('score clamping', () => {
|
|
258
|
-
it('clamps boosted scores to maximum of 1.0', async () => {
|
|
259
|
-
const gen1 = new MockGenerator();
|
|
260
|
-
gen1.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.9, 'new')]);
|
|
261
|
-
|
|
262
|
-
const gen2 = new MockGenerator();
|
|
263
|
-
gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.9, 'new')]);
|
|
264
|
-
|
|
265
|
-
const composite = new CompositeGenerator([gen1, gen2]);
|
|
266
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
267
|
-
|
|
268
|
-
expect(result).toHaveLength(1);
|
|
269
|
-
// avg = 0.9, boost = 1.1, result = 0.99 (would be 0.99, not clamped)
|
|
270
|
-
// But with higher scores:
|
|
271
|
-
expect(result[0].score).toBeLessThanOrEqual(1.0);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it('clamps to 1.0 when boosted score exceeds maximum', async () => {
|
|
275
|
-
const gen1 = new MockGenerator();
|
|
276
|
-
gen1.setWeightedCards([makeWeightedCard('card-1', 'course-1', 1.0, 'new')]);
|
|
277
|
-
|
|
278
|
-
const gen2 = new MockGenerator();
|
|
279
|
-
gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 1.0, 'new')]);
|
|
280
|
-
|
|
281
|
-
const gen3 = new MockGenerator();
|
|
282
|
-
gen3.setWeightedCards([makeWeightedCard('card-1', 'course-1', 1.0, 'new')]);
|
|
283
|
-
|
|
284
|
-
const composite = new CompositeGenerator([gen1, gen2, gen3]);
|
|
285
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
286
|
-
|
|
287
|
-
expect(result).toHaveLength(1);
|
|
288
|
-
// avg = 1.0, boost = 1.2, result would be 1.2 but clamped to 1.0
|
|
289
|
-
expect(result[0].score).toBe(1.0);
|
|
290
|
-
});
|
|
291
|
-
});
|
|
292
|
-
|
|
293
257
|
describe('sorting and limiting', () => {
|
|
294
258
|
it('returns cards sorted by score descending', async () => {
|
|
295
259
|
const gen1 = new MockGenerator();
|
|
@@ -300,7 +264,7 @@ describe('CompositeGenerator', () => {
|
|
|
300
264
|
]);
|
|
301
265
|
|
|
302
266
|
const composite = new CompositeGenerator([gen1]);
|
|
303
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
267
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
304
268
|
|
|
305
269
|
expect(result).toHaveLength(3);
|
|
306
270
|
expect(result[0].cardId).toBe('card-high');
|
|
@@ -319,7 +283,7 @@ describe('CompositeGenerator', () => {
|
|
|
319
283
|
gen2.setWeightedCards([makeWeightedCard('card-boosted', 'course-1', 0.5, 'new')]);
|
|
320
284
|
|
|
321
285
|
const composite = new CompositeGenerator([gen1, gen2]);
|
|
322
|
-
const result = await composite.getWeightedCards(10, mockContext);
|
|
286
|
+
const { cards: result } = await composite.getWeightedCards(10, mockContext);
|
|
323
287
|
|
|
324
288
|
expect(result).toHaveLength(2);
|
|
325
289
|
// card-boosted: avg=0.5, boost=1.1, final=0.55
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
2
2
|
import { Pipeline } from '../../../src/core/navigators/Pipeline';
|
|
3
3
|
import { WeightedCard, ContentNavigator } from '../../../src/core/navigators/index';
|
|
4
4
|
import { CardFilter, FilterContext } from '../../../src/core/navigators/filters/types';
|
|
5
|
+
import type { GeneratorResult } from '../../../src/core/navigators/generators/types';
|
|
5
6
|
|
|
6
7
|
import { CourseDBInterface } from '../../../src/core/interfaces/courseDB';
|
|
7
8
|
import { UserDBInterface } from '../../../src/core/interfaces/userDB';
|
|
@@ -22,8 +23,8 @@ class MockGenerator extends ContentNavigator {
|
|
|
22
23
|
this.cards = cards;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
async getWeightedCards(limit: number): Promise<
|
|
26
|
-
return this.cards.slice(0, limit);
|
|
26
|
+
async getWeightedCards(limit: number): Promise<GeneratorResult> {
|
|
27
|
+
return { cards: this.cards.slice(0, limit) };
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -143,7 +144,7 @@ describe('Pipeline', () => {
|
|
|
143
144
|
const generator = new MockGenerator(cards);
|
|
144
145
|
const pipeline = new Pipeline(generator, [], mockUser, mockCourse);
|
|
145
146
|
|
|
146
|
-
const result = await pipeline.getWeightedCards(10);
|
|
147
|
+
const { cards: result } = await pipeline.getWeightedCards(10);
|
|
147
148
|
|
|
148
149
|
expect(result).toHaveLength(2);
|
|
149
150
|
expect(result[0].cardId).toBe('card-1');
|
|
@@ -155,7 +156,7 @@ describe('Pipeline', () => {
|
|
|
155
156
|
const generator = new MockGenerator(cards);
|
|
156
157
|
const pipeline = new Pipeline(generator, [], mockUser, mockCourse);
|
|
157
158
|
|
|
158
|
-
const result = await pipeline.getWeightedCards(10);
|
|
159
|
+
const { cards: result } = await pipeline.getWeightedCards(10);
|
|
159
160
|
|
|
160
161
|
expect(result[0].cardId).toBe('high');
|
|
161
162
|
expect(result[1].cardId).toBe('mid');
|
|
@@ -172,7 +173,7 @@ describe('Pipeline', () => {
|
|
|
172
173
|
const generator = new MockGenerator(cards);
|
|
173
174
|
const pipeline = new Pipeline(generator, [], mockUser, mockCourse);
|
|
174
175
|
|
|
175
|
-
const result = await pipeline.getWeightedCards(2);
|
|
176
|
+
const { cards: result } = await pipeline.getWeightedCards(2);
|
|
176
177
|
|
|
177
178
|
expect(result).toHaveLength(2);
|
|
178
179
|
expect(result[0].cardId).toBe('card-1');
|
|
@@ -183,7 +184,7 @@ describe('Pipeline', () => {
|
|
|
183
184
|
const generator = new MockGenerator([]);
|
|
184
185
|
const pipeline = new Pipeline(generator, [], mockUser, mockCourse);
|
|
185
186
|
|
|
186
|
-
const result = await pipeline.getWeightedCards(10);
|
|
187
|
+
const { cards: result } = await pipeline.getWeightedCards(10);
|
|
187
188
|
|
|
188
189
|
expect(result).toHaveLength(0);
|
|
189
190
|
});
|
|
@@ -196,7 +197,7 @@ describe('Pipeline', () => {
|
|
|
196
197
|
const filter = createMultiplierFilter('Half', 0.5);
|
|
197
198
|
const pipeline = new Pipeline(generator, [filter], mockUser, mockCourse);
|
|
198
199
|
|
|
199
|
-
const result = await pipeline.getWeightedCards(10);
|
|
200
|
+
const { cards: result } = await pipeline.getWeightedCards(10);
|
|
200
201
|
|
|
201
202
|
expect(result[0].score).toBe(0.5); // 1.0 * 0.5
|
|
202
203
|
expect(result[1].score).toBe(0.4); // 0.8 * 0.5
|
|
@@ -209,7 +210,7 @@ describe('Pipeline', () => {
|
|
|
209
210
|
const filter2 = createMultiplierFilter('Double', 2.0);
|
|
210
211
|
const pipeline = new Pipeline(generator, [filter1, filter2], mockUser, mockCourse);
|
|
211
212
|
|
|
212
|
-
const result = await pipeline.getWeightedCards(10);
|
|
213
|
+
const { cards: result } = await pipeline.getWeightedCards(10);
|
|
213
214
|
|
|
214
215
|
// 1.0 * 0.5 * 2.0 = 1.0
|
|
215
216
|
expect(result[0].score).toBe(1.0);
|
|
@@ -221,7 +222,7 @@ describe('Pipeline', () => {
|
|
|
221
222
|
const filter = createBlockingFilter('Blocker', ['block']);
|
|
222
223
|
const pipeline = new Pipeline(generator, [filter], mockUser, mockCourse);
|
|
223
224
|
|
|
224
|
-
const result = await pipeline.getWeightedCards(10);
|
|
225
|
+
const { cards: result } = await pipeline.getWeightedCards(10);
|
|
225
226
|
|
|
226
227
|
expect(result).toHaveLength(1);
|
|
227
228
|
expect(result[0].cardId).toBe('keep');
|
|
@@ -234,7 +235,7 @@ describe('Pipeline', () => {
|
|
|
234
235
|
const filter2 = createMultiplierFilter('Filter B', 0.8);
|
|
235
236
|
const pipeline = new Pipeline(generator, [filter1, filter2], mockUser, mockCourse);
|
|
236
237
|
|
|
237
|
-
const result = await pipeline.getWeightedCards(10);
|
|
238
|
+
const { cards: result } = await pipeline.getWeightedCards(10);
|
|
238
239
|
|
|
239
240
|
expect(result[0].provenance).toHaveLength(3); // generator + 2 filters
|
|
240
241
|
expect(result[0].provenance[0].strategyName).toBe('Test Generator');
|
|
@@ -253,12 +254,12 @@ describe('Pipeline', () => {
|
|
|
253
254
|
// Order: A then B
|
|
254
255
|
const generator1 = new MockGenerator([...cards]);
|
|
255
256
|
const pipeline1 = new Pipeline(generator1, [filterA, filterB], mockUser, mockCourse);
|
|
256
|
-
const result1 = await pipeline1.getWeightedCards(10);
|
|
257
|
+
const { cards: result1 } = await pipeline1.getWeightedCards(10);
|
|
257
258
|
|
|
258
259
|
// Order: B then A
|
|
259
260
|
const generator2 = new MockGenerator([...cards]);
|
|
260
261
|
const pipeline2 = new Pipeline(generator2, [filterB, filterA], mockUser, mockCourse);
|
|
261
|
-
const result2 = await pipeline2.getWeightedCards(10);
|
|
262
|
+
const { cards: result2 } = await pipeline2.getWeightedCards(10);
|
|
262
263
|
|
|
263
264
|
// Both should yield 1.0 * 0.5 * 0.8 = 0.4
|
|
264
265
|
expect(result1[0].score).toBeCloseTo(0.4);
|