@vue-skuilder/db 0.1.32-e → 0.1.32-f
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 +461 -30
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +461 -30
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +461 -30
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +461 -30
- 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.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +467 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +467 -32
- 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/src/impl/couch/courseDB.ts +3 -2
- package/src/study/SessionController.ts +1 -0
- package/src/study/services/CardHydrationService.ts +6 -1
- 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,
|
|
@@ -524,7 +524,7 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
524
524
|
): Promise<PouchDB.Core.GetMeta & PouchDB.Core.Document<T>> {
|
|
525
525
|
// Use this.db (local when available) for read operations.
|
|
526
526
|
// Falls back to the standalone helper (always remote) only if needed.
|
|
527
|
-
return await this.db.get<T>(id, options) as PouchDB.Core.GetMeta & PouchDB.Core.Document<T>;
|
|
527
|
+
return await this.db.get<T>(id, options ?? {}) as PouchDB.Core.GetMeta & PouchDB.Core.Document<T>;
|
|
528
528
|
}
|
|
529
529
|
|
|
530
530
|
async getCourseDocs<T extends SkuilderCourseData>(
|
|
@@ -629,7 +629,8 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
629
629
|
);
|
|
630
630
|
return pipeline;
|
|
631
631
|
} catch (e) {
|
|
632
|
-
|
|
632
|
+
const msg = e instanceof Error ? `${e.message}\n${e.stack}` : JSON.stringify(e);
|
|
633
|
+
logger.error(`[courseDB] Error creating navigator: ${msg}`);
|
|
633
634
|
throw e;
|
|
634
635
|
}
|
|
635
636
|
}
|
|
@@ -54,6 +54,7 @@ export interface HydratedCard<TView = unknown> {
|
|
|
54
54
|
item: StudySessionItem;
|
|
55
55
|
view: TView;
|
|
56
56
|
data: any[];
|
|
57
|
+
tags: string[];
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/**
|
|
@@ -195,7 +196,10 @@ export class CardHydrationService<TView = unknown> {
|
|
|
195
196
|
|
|
196
197
|
try {
|
|
197
198
|
const courseDB = this.getCourseDB(item.courseID);
|
|
198
|
-
const cardData = await
|
|
199
|
+
const [cardData, tagsByCard] = await Promise.all([
|
|
200
|
+
courseDB.getCourseDoc<CardData>(item.cardID),
|
|
201
|
+
courseDB.getAppliedTagsBatch([item.cardID]),
|
|
202
|
+
]);
|
|
199
203
|
|
|
200
204
|
if (!isCourseElo(cardData.elo)) {
|
|
201
205
|
cardData.elo = toCourseElo(cardData.elo);
|
|
@@ -234,6 +238,7 @@ export class CardHydrationService<TView = unknown> {
|
|
|
234
238
|
item,
|
|
235
239
|
view,
|
|
236
240
|
data,
|
|
241
|
+
tags: tagsByCard.get(item.cardID) ?? [],
|
|
237
242
|
});
|
|
238
243
|
|
|
239
244
|
logger.debug(`[CardHydrationService] Hydrated card ${item.cardID}`);
|
|
@@ -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
|