@vue-skuilder/db 0.1.31-b → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
- package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
- package/dist/core/index.d.cts +34 -3
- package/dist/core/index.d.ts +34 -3
- package/dist/core/index.js +510 -50
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +510 -50
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
- package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
- package/dist/impl/couch/index.d.cts +156 -4
- package/dist/impl/couch/index.d.ts +156 -4
- package/dist/impl/couch/index.js +730 -41
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +729 -41
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -2
- package/dist/impl/static/index.d.ts +3 -2
- package/dist/impl/static/index.js +467 -31
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +467 -31
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +64 -3
- package/dist/index.d.ts +64 -3
- package/dist/index.js +948 -72
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +948 -72
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +6 -0
- package/src/core/interfaces/courseDB.ts +6 -0
- package/src/core/interfaces/dataLayerProvider.ts +20 -0
- package/src/core/navigators/Pipeline.ts +414 -9
- package/src/core/navigators/PipelineAssembler.ts +23 -18
- package/src/core/navigators/PipelineDebugger.ts +35 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
- package/src/core/navigators/generators/prescribed.ts +95 -0
- package/src/core/navigators/index.ts +12 -0
- package/src/impl/common/BaseUserDB.ts +4 -1
- package/src/impl/couch/CourseSyncService.ts +356 -0
- package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
- package/src/impl/couch/courseDB.ts +60 -13
- package/src/impl/couch/index.ts +1 -0
- package/src/impl/static/courseDB.ts +5 -0
- package/src/study/ItemQueue.ts +42 -0
- package/src/study/SessionController.ts +195 -22
- package/src/study/SpacedRepetition.ts +3 -1
- package/tests/core/navigators/Pipeline.test.ts +1 -1
- package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.1.31
|
|
7
|
+
"version": "0.1.31",
|
|
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.31
|
|
51
|
+
"@vue-skuilder/common": "0.1.31",
|
|
52
52
|
"cross-fetch": "^4.1.0",
|
|
53
53
|
"moment": "^2.29.4",
|
|
54
54
|
"pouchdb": "^9.0.0",
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
"vite": "^7.0.0",
|
|
63
63
|
"vitest": "^4.0.15"
|
|
64
64
|
},
|
|
65
|
-
"stableVersion": "0.1.
|
|
65
|
+
"stableVersion": "0.1.31"
|
|
66
66
|
}
|
|
@@ -79,6 +79,12 @@ export interface StudyContentSource {
|
|
|
79
79
|
* Used for recording learning outcomes.
|
|
80
80
|
*/
|
|
81
81
|
getOrchestrationContext?(): Promise<OrchestrationContext>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Set ephemeral hints for the next pipeline run.
|
|
85
|
+
* No-op for sources that don't support hints.
|
|
86
|
+
*/
|
|
87
|
+
setEphemeralHints?(hints: Record<string, unknown>): void;
|
|
82
88
|
}
|
|
83
89
|
// #endregion docs_StudyContentSource
|
|
84
90
|
|
|
@@ -100,6 +100,12 @@ export interface CourseDBInterface extends NavigationStrategyManager, StudyConte
|
|
|
100
100
|
*/
|
|
101
101
|
getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>>;
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Get all card IDs in the course.
|
|
105
|
+
* Used by diagnostics to scan the full card space.
|
|
106
|
+
*/
|
|
107
|
+
getAllCardIds(): Promise<string[]>;
|
|
108
|
+
|
|
103
109
|
/**
|
|
104
110
|
* Add a tag to a card
|
|
105
111
|
*/
|
|
@@ -56,4 +56,24 @@ export interface DataLayerProvider {
|
|
|
56
56
|
* Check if this data layer is read-only
|
|
57
57
|
*/
|
|
58
58
|
isReadOnly(): boolean;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Trigger local replication of a course database.
|
|
62
|
+
*
|
|
63
|
+
* When a course opts in via `CourseConfig.localSync.enabled`, this method
|
|
64
|
+
* replicates the remote course DB to a local PouchDB instance. Subsequent
|
|
65
|
+
* `getCourseDB()` calls for that course will return a CourseDB that reads
|
|
66
|
+
* from the local replica (fast, no network) and writes to the remote
|
|
67
|
+
* (ELO updates, admin ops).
|
|
68
|
+
*
|
|
69
|
+
* Safe to call multiple times — concurrent calls coalesce. Returns when
|
|
70
|
+
* sync is complete (or immediately if already synced / disabled).
|
|
71
|
+
*
|
|
72
|
+
* Implementations that don't support local sync may no-op.
|
|
73
|
+
*
|
|
74
|
+
* @param courseId - The course to sync locally
|
|
75
|
+
* @param forceEnabled - Skip CourseConfig check and sync regardless.
|
|
76
|
+
* Use when the caller already knows local sync is desired.
|
|
77
|
+
*/
|
|
78
|
+
ensureCourseSynced?(courseId: string, forceEnabled?: boolean): Promise<void>;
|
|
59
79
|
}
|
|
@@ -7,7 +7,60 @@ import type { CardFilter, FilterContext } from './filters/types';
|
|
|
7
7
|
import type { CardGenerator, GeneratorContext } from './generators/types';
|
|
8
8
|
import { logger } from '../../util/logger';
|
|
9
9
|
import { createOrchestrationContext, OrchestrationContext } from '../orchestration';
|
|
10
|
-
import { captureRun, buildRunReport, type GeneratorSummary, type FilterImpact } from './PipelineDebugger';
|
|
10
|
+
import { captureRun, buildRunReport, registerPipelineForDebug, type GeneratorSummary, type FilterImpact } from './PipelineDebugger';
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// REPLAN HINTS
|
|
14
|
+
// ============================================================================
|
|
15
|
+
//
|
|
16
|
+
// Ephemeral, one-shot scoring hints passed at replan time.
|
|
17
|
+
// Applied after the filter chain, consumed after one pipeline run.
|
|
18
|
+
//
|
|
19
|
+
// Tag patterns support glob-style matching:
|
|
20
|
+
// 'gpc:exercise:t-T' — exact match
|
|
21
|
+
// 'gpc:intro:*' — all intro tags
|
|
22
|
+
// 'gpc:exercise:t-*' — all t-variant exercises
|
|
23
|
+
//
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ephemeral pipeline hints for a single run.
|
|
27
|
+
* All fields are optional. Tag/card patterns support `*` wildcards.
|
|
28
|
+
*/
|
|
29
|
+
export interface ReplanHints {
|
|
30
|
+
/** Multiply scores for cards matching these tag patterns. */
|
|
31
|
+
boostTags?: Record<string, number>;
|
|
32
|
+
/** Multiply scores for these specific card IDs (glob patterns). */
|
|
33
|
+
boostCards?: Record<string, number>;
|
|
34
|
+
/** Cards matching these tag patterns MUST appear in results. */
|
|
35
|
+
requireTags?: string[];
|
|
36
|
+
/** These specific card IDs MUST appear in results. */
|
|
37
|
+
requireCards?: string[];
|
|
38
|
+
/** Remove cards matching these tag patterns from results. */
|
|
39
|
+
excludeTags?: string[];
|
|
40
|
+
/** Remove these specific card IDs from results. */
|
|
41
|
+
excludeCards?: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convert a glob pattern (with `*` wildcards) to a RegExp.
|
|
46
|
+
* Only `*` is supported as a wildcard (matches any characters).
|
|
47
|
+
*/
|
|
48
|
+
function globToRegex(pattern: string): RegExp {
|
|
49
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
50
|
+
const withWildcards = escaped.replace(/\*/g, '.*');
|
|
51
|
+
return new RegExp(`^${withWildcards}$`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Test whether a string matches a glob pattern. */
|
|
55
|
+
function globMatch(value: string, pattern: string): boolean {
|
|
56
|
+
if (!pattern.includes('*')) return value === pattern;
|
|
57
|
+
return globToRegex(pattern).test(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Test whether any of a card's tags match a glob pattern. */
|
|
61
|
+
function cardMatchesTagPattern(card: WeightedCard, pattern: string): boolean {
|
|
62
|
+
return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
|
|
63
|
+
}
|
|
11
64
|
|
|
12
65
|
// ============================================================================
|
|
13
66
|
// PIPELINE LOGGING HELPERS
|
|
@@ -79,6 +132,32 @@ function logExecutionSummary(
|
|
|
79
132
|
);
|
|
80
133
|
}
|
|
81
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Log all result cards with score, cardId, and key provenance.
|
|
137
|
+
* Toggle: set VERBOSE_RESULTS = true to enable.
|
|
138
|
+
*/
|
|
139
|
+
const VERBOSE_RESULTS = true;
|
|
140
|
+
|
|
141
|
+
function logResultCards(cards: WeightedCard[]): void {
|
|
142
|
+
if (!VERBOSE_RESULTS || cards.length === 0) return;
|
|
143
|
+
|
|
144
|
+
logger.info(`[Pipeline] Results (${cards.length} cards):`);
|
|
145
|
+
for (let i = 0; i < cards.length; i++) {
|
|
146
|
+
const c = cards[i];
|
|
147
|
+
const tags = c.tags?.slice(0, 3).join(', ') || '';
|
|
148
|
+
const filters = c.provenance
|
|
149
|
+
.filter((p) => p.strategy === 'hierarchyDefinition' || p.strategy === 'priorityDefinition' || p.strategy === 'interferenceFilter' || p.strategy === 'letterGating' || p.strategy === 'ephemeralHint')
|
|
150
|
+
.map((p) => {
|
|
151
|
+
const arrow = p.action === 'boosted' ? '↑' : p.action === 'penalized' ? '↓' : '=';
|
|
152
|
+
return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
|
|
153
|
+
})
|
|
154
|
+
.join(' | ');
|
|
155
|
+
logger.info(
|
|
156
|
+
`[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ''}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
82
161
|
/**
|
|
83
162
|
* Log provenance trails for cards.
|
|
84
163
|
* Shows the complete scoring history for each card through the pipeline.
|
|
@@ -145,6 +224,30 @@ export class Pipeline extends ContentNavigator {
|
|
|
145
224
|
private generator: CardGenerator;
|
|
146
225
|
private filters: CardFilter[];
|
|
147
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Cached orchestration context. Course config and salt don't change within
|
|
229
|
+
* a page load, so we build the orchestration context once and reuse it on
|
|
230
|
+
* subsequent getWeightedCards() calls (e.g. mid-session replans).
|
|
231
|
+
*
|
|
232
|
+
* This eliminates a remote getCourseConfig() round trip per pipeline run.
|
|
233
|
+
*/
|
|
234
|
+
private _cachedOrchestration: OrchestrationContext | null = null;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Persistent tag cache. Maps cardId → tag names.
|
|
238
|
+
*
|
|
239
|
+
* Tags are static within a session (they're set at card generation time),
|
|
240
|
+
* so we cache them across pipeline runs. On replans, many of the same cards
|
|
241
|
+
* reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
|
|
242
|
+
*/
|
|
243
|
+
private _tagCache: Map<string, string[]> = new Map();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One-shot replan hints. Applied after the filter chain on the next
|
|
247
|
+
* getWeightedCards() call, then cleared.
|
|
248
|
+
*/
|
|
249
|
+
private _ephemeralHints: ReplanHints | null = null;
|
|
250
|
+
|
|
148
251
|
/**
|
|
149
252
|
* Create a new pipeline.
|
|
150
253
|
*
|
|
@@ -175,6 +278,20 @@ export class Pipeline extends ContentNavigator {
|
|
|
175
278
|
});
|
|
176
279
|
// Toggle pipeline configuration logging:
|
|
177
280
|
logPipelineConfig(generator, filters);
|
|
281
|
+
|
|
282
|
+
// Register for debug API access
|
|
283
|
+
registerPipelineForDebug(this);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Set one-shot hints for the next pipeline run.
|
|
288
|
+
* Consumed after one getWeightedCards() call, then cleared.
|
|
289
|
+
*
|
|
290
|
+
* Overrides ContentNavigator.setEphemeralHints() no-op.
|
|
291
|
+
*/
|
|
292
|
+
override setEphemeralHints(hints: Record<string, unknown>): void {
|
|
293
|
+
this._ephemeralHints = hints as ReplanHints;
|
|
294
|
+
logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
|
|
178
295
|
}
|
|
179
296
|
|
|
180
297
|
/**
|
|
@@ -192,12 +309,17 @@ export class Pipeline extends ContentNavigator {
|
|
|
192
309
|
* @returns Cards sorted by score descending
|
|
193
310
|
*/
|
|
194
311
|
async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
312
|
+
const t0 = performance.now();
|
|
313
|
+
|
|
195
314
|
// Build shared context once
|
|
196
315
|
const context = await this.buildContext();
|
|
316
|
+
const tContext = performance.now();
|
|
197
317
|
|
|
198
|
-
// Over-fetch from generator to
|
|
199
|
-
|
|
200
|
-
|
|
318
|
+
// Over-fetch from generator to give filters a wide candidate pool.
|
|
319
|
+
// With local course DB the cost is negligible (~20ms for 500 cards).
|
|
320
|
+
// Filters (hierarchy, letter gating, etc.) can be aggressive — a wide
|
|
321
|
+
// pool ensures enough well-indicated candidates survive.
|
|
322
|
+
const fetchLimit = 500;
|
|
201
323
|
|
|
202
324
|
logger.debug(
|
|
203
325
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
@@ -205,6 +327,7 @@ export class Pipeline extends ContentNavigator {
|
|
|
205
327
|
|
|
206
328
|
// Get candidates from generator, passing context
|
|
207
329
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
330
|
+
const tGenerate = performance.now();
|
|
208
331
|
const generatedCount = cards.length;
|
|
209
332
|
|
|
210
333
|
// Capture generator breakdown for debugging (if CompositeGenerator)
|
|
@@ -239,6 +362,7 @@ export class Pipeline extends ContentNavigator {
|
|
|
239
362
|
|
|
240
363
|
// Batch hydrate tags before filters run
|
|
241
364
|
cards = await this.hydrateTags(cards);
|
|
365
|
+
const tHydrate = performance.now();
|
|
242
366
|
|
|
243
367
|
// Keep a copy of all cards for debug capture (before filtering removes any)
|
|
244
368
|
const allCardsBeforeFiltering = [...cards];
|
|
@@ -268,12 +392,26 @@ export class Pipeline extends ContentNavigator {
|
|
|
268
392
|
// Remove zero-score cards (hard filtered)
|
|
269
393
|
cards = cards.filter((c) => c.score > 0);
|
|
270
394
|
|
|
395
|
+
// Apply ephemeral hints (one-shot, post-filter)
|
|
396
|
+
const hints = this._ephemeralHints;
|
|
397
|
+
if (hints) {
|
|
398
|
+
this._ephemeralHints = null; // consume
|
|
399
|
+
cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
|
|
400
|
+
}
|
|
401
|
+
|
|
271
402
|
// Sort by score descending
|
|
272
403
|
cards.sort((a, b) => b.score - a.score);
|
|
273
404
|
|
|
274
405
|
// Return top N
|
|
406
|
+
const tFilter = performance.now();
|
|
275
407
|
const result = cards.slice(0, limit);
|
|
276
408
|
|
|
409
|
+
logger.info(
|
|
410
|
+
`[Pipeline:timing] total=${(tFilter - t0).toFixed(0)}ms ` +
|
|
411
|
+
`(context=${(tContext - t0).toFixed(0)} generate=${(tGenerate - tContext).toFixed(0)} ` +
|
|
412
|
+
`hydrate=${(tHydrate - tGenerate).toFixed(0)} filter=${(tFilter - tHydrate).toFixed(0)})`
|
|
413
|
+
);
|
|
414
|
+
|
|
277
415
|
// Toggle execution summary logging:
|
|
278
416
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
279
417
|
logExecutionSummary(
|
|
@@ -285,6 +423,9 @@ export class Pipeline extends ContentNavigator {
|
|
|
285
423
|
filterImpacts
|
|
286
424
|
);
|
|
287
425
|
|
|
426
|
+
// Toggle verbose result listing:
|
|
427
|
+
logResultCards(result);
|
|
428
|
+
|
|
288
429
|
// Toggle provenance logging (shows scoring history for top cards):
|
|
289
430
|
logCardProvenance(result, 3);
|
|
290
431
|
|
|
@@ -316,6 +457,10 @@ export class Pipeline extends ContentNavigator {
|
|
|
316
457
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
317
458
|
* making individual getAppliedTags() calls.
|
|
318
459
|
*
|
|
460
|
+
* Uses a persistent tag cache across pipeline runs — tags are static within
|
|
461
|
+
* a session, so cards seen in a prior run (e.g. before a replan) don't
|
|
462
|
+
* require a second DB query.
|
|
463
|
+
*
|
|
319
464
|
* @param cards - Cards to hydrate
|
|
320
465
|
* @returns Cards with tags populated
|
|
321
466
|
*/
|
|
@@ -324,18 +469,153 @@ export class Pipeline extends ContentNavigator {
|
|
|
324
469
|
return cards;
|
|
325
470
|
}
|
|
326
471
|
|
|
327
|
-
|
|
328
|
-
const
|
|
472
|
+
// Separate cards with cached tags from those needing a DB query
|
|
473
|
+
const uncachedIds: string[] = [];
|
|
474
|
+
for (const card of cards) {
|
|
475
|
+
if (!this._tagCache.has(card.cardId)) {
|
|
476
|
+
uncachedIds.push(card.cardId);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Only query the DB for cards not already in cache
|
|
481
|
+
if (uncachedIds.length > 0) {
|
|
482
|
+
const freshTags = await this.course!.getAppliedTagsBatch(uncachedIds);
|
|
483
|
+
for (const [cardId, tags] of freshTags) {
|
|
484
|
+
this._tagCache.set(cardId, tags);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Build the tagsByCard map from cache (for logging compatibility)
|
|
489
|
+
const tagsByCard = new Map<string, string[]>();
|
|
490
|
+
for (const card of cards) {
|
|
491
|
+
tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
|
|
492
|
+
}
|
|
329
493
|
|
|
330
494
|
// Toggle tag hydration logging:
|
|
331
495
|
logTagHydration(cards, tagsByCard);
|
|
332
496
|
|
|
333
497
|
return cards.map((card) => ({
|
|
334
498
|
...card,
|
|
335
|
-
tags:
|
|
499
|
+
tags: this._tagCache.get(card.cardId) ?? [],
|
|
336
500
|
}));
|
|
337
501
|
}
|
|
338
502
|
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// Ephemeral hints application
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Apply one-shot replan hints to the post-filter card set.
|
|
509
|
+
*
|
|
510
|
+
* Order of operations:
|
|
511
|
+
* 1. Exclude (remove unwanted cards)
|
|
512
|
+
* 2. Boost (multiply scores)
|
|
513
|
+
* 3. Require (inject must-have cards from the full pre-filter pool)
|
|
514
|
+
*
|
|
515
|
+
* @param cards - Post-filter cards (score > 0)
|
|
516
|
+
* @param hints - The ephemeral hints to apply
|
|
517
|
+
* @param allCards - Full pre-filter card pool (for require injection)
|
|
518
|
+
*/
|
|
519
|
+
private applyHints(
|
|
520
|
+
cards: WeightedCard[],
|
|
521
|
+
hints: ReplanHints,
|
|
522
|
+
allCards: WeightedCard[]
|
|
523
|
+
): WeightedCard[] {
|
|
524
|
+
const beforeCount = cards.length;
|
|
525
|
+
|
|
526
|
+
// 1. Exclude
|
|
527
|
+
if (hints.excludeCards?.length) {
|
|
528
|
+
cards = cards.filter(
|
|
529
|
+
(c) => !hints.excludeCards!.some((pat) => globMatch(c.cardId, pat))
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
if (hints.excludeTags?.length) {
|
|
533
|
+
cards = cards.filter(
|
|
534
|
+
(c) => !hints.excludeTags!.some((pat) => cardMatchesTagPattern(c, pat))
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 2. Boost
|
|
539
|
+
if (hints.boostTags) {
|
|
540
|
+
for (const [pattern, factor] of Object.entries(hints.boostTags)) {
|
|
541
|
+
for (const card of cards) {
|
|
542
|
+
if (cardMatchesTagPattern(card, pattern)) {
|
|
543
|
+
card.score *= factor;
|
|
544
|
+
card.provenance.push({
|
|
545
|
+
strategy: 'ephemeralHint',
|
|
546
|
+
strategyId: 'ephemeral-hint',
|
|
547
|
+
strategyName: 'Replan Hint',
|
|
548
|
+
action: 'boosted',
|
|
549
|
+
score: card.score,
|
|
550
|
+
reason: `boostTag ${pattern} ×${factor}`,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (hints.boostCards) {
|
|
557
|
+
for (const [pattern, factor] of Object.entries(hints.boostCards)) {
|
|
558
|
+
for (const card of cards) {
|
|
559
|
+
if (globMatch(card.cardId, pattern)) {
|
|
560
|
+
card.score *= factor;
|
|
561
|
+
card.provenance.push({
|
|
562
|
+
strategy: 'ephemeralHint',
|
|
563
|
+
strategyId: 'ephemeral-hint',
|
|
564
|
+
strategyName: 'Replan Hint',
|
|
565
|
+
action: 'boosted',
|
|
566
|
+
score: card.score,
|
|
567
|
+
reason: `boostCard ${pattern} ×${factor}`,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 3. Require — inject from the full pool if not already present
|
|
575
|
+
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
576
|
+
const inject = (card: WeightedCard, reason: string) => {
|
|
577
|
+
if (!cardIds.has(card.cardId)) {
|
|
578
|
+
// Give required cards a floor score so they sort above zero-score filler
|
|
579
|
+
const floorScore = Math.max(card.score, 1.0);
|
|
580
|
+
cards.push({
|
|
581
|
+
...card,
|
|
582
|
+
score: floorScore,
|
|
583
|
+
provenance: [
|
|
584
|
+
...card.provenance,
|
|
585
|
+
{
|
|
586
|
+
strategy: 'ephemeralHint',
|
|
587
|
+
strategyId: 'ephemeral-hint',
|
|
588
|
+
strategyName: 'Replan Hint',
|
|
589
|
+
action: 'boosted',
|
|
590
|
+
score: floorScore,
|
|
591
|
+
reason,
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
});
|
|
595
|
+
cardIds.add(card.cardId);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
if (hints.requireCards?.length) {
|
|
600
|
+
for (const pattern of hints.requireCards) {
|
|
601
|
+
for (const card of allCards) {
|
|
602
|
+
if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (hints.requireTags?.length) {
|
|
607
|
+
for (const pattern of hints.requireTags) {
|
|
608
|
+
for (const card of allCards) {
|
|
609
|
+
if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
logger.info(`[Pipeline] Hints applied: ${beforeCount} → ${cards.length} cards`);
|
|
615
|
+
|
|
616
|
+
return cards;
|
|
617
|
+
}
|
|
618
|
+
|
|
339
619
|
/**
|
|
340
620
|
* Build shared context for generator and filters.
|
|
341
621
|
*
|
|
@@ -355,8 +635,13 @@ export class Pipeline extends ContentNavigator {
|
|
|
355
635
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
356
636
|
}
|
|
357
637
|
|
|
358
|
-
//
|
|
359
|
-
|
|
638
|
+
// Reuse cached orchestration context if available (course config is stable
|
|
639
|
+
// within a page load). This avoids a remote getCourseConfig() call on
|
|
640
|
+
// subsequent pipeline runs (e.g. mid-session replans).
|
|
641
|
+
if (!this._cachedOrchestration) {
|
|
642
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user!, this.course!);
|
|
643
|
+
}
|
|
644
|
+
const orchestration = this._cachedOrchestration;
|
|
360
645
|
|
|
361
646
|
return {
|
|
362
647
|
user: this.user!,
|
|
@@ -413,4 +698,124 @@ export class Pipeline extends ContentNavigator {
|
|
|
413
698
|
|
|
414
699
|
return [...new Set(ids)];
|
|
415
700
|
}
|
|
701
|
+
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// Card-space diagnostic
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Scan every card in the course through the filter chain and report
|
|
708
|
+
* how many are "well indicated" (score >= threshold) for the current user.
|
|
709
|
+
*
|
|
710
|
+
* Also reports how many well-indicated cards the user has NOT yet encountered.
|
|
711
|
+
*
|
|
712
|
+
* Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
|
|
713
|
+
*/
|
|
714
|
+
async diagnoseCardSpace(opts?: { threshold?: number }): Promise<CardSpaceDiagnosis> {
|
|
715
|
+
const THRESHOLD = opts?.threshold ?? 0.10;
|
|
716
|
+
const t0 = performance.now();
|
|
717
|
+
|
|
718
|
+
// 1. Get all card IDs
|
|
719
|
+
const allCardIds = await this.course!.getAllCardIds();
|
|
720
|
+
|
|
721
|
+
// 2. Build dummy WeightedCards (score=1.0, no provenance)
|
|
722
|
+
let cards: WeightedCard[] = allCardIds.map((cardId) => ({
|
|
723
|
+
cardId,
|
|
724
|
+
courseId: this.course!.getCourseID(),
|
|
725
|
+
score: 1.0,
|
|
726
|
+
provenance: [],
|
|
727
|
+
}));
|
|
728
|
+
|
|
729
|
+
// 3. Hydrate tags
|
|
730
|
+
cards = await this.hydrateTags(cards);
|
|
731
|
+
|
|
732
|
+
// 4. Run through filters
|
|
733
|
+
const context = await this.buildContext();
|
|
734
|
+
const filterBreakdown: Array<{ name: string; wellIndicated: number }> = [];
|
|
735
|
+
|
|
736
|
+
// Track cumulative filter effects
|
|
737
|
+
for (const filter of this.filters) {
|
|
738
|
+
cards = await filter.transform(cards, context);
|
|
739
|
+
const wi = cards.filter((c) => c.score >= THRESHOLD).length;
|
|
740
|
+
filterBreakdown.push({ name: filter.name, wellIndicated: wi });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 5. Count well-indicated
|
|
744
|
+
const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
|
|
745
|
+
const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
|
|
746
|
+
|
|
747
|
+
// 6. Get encountered cards
|
|
748
|
+
let encounteredIds: Set<string>;
|
|
749
|
+
try {
|
|
750
|
+
const courseId = this.course!.getCourseID();
|
|
751
|
+
const seenCards = await this.user!.getSeenCards(courseId);
|
|
752
|
+
encounteredIds = new Set(seenCards);
|
|
753
|
+
} catch {
|
|
754
|
+
encounteredIds = new Set();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
|
|
758
|
+
|
|
759
|
+
// 7. Group by card type
|
|
760
|
+
const byType = new Map<string, { total: number; wellIndicated: number; new: number }>();
|
|
761
|
+
for (const card of cards) {
|
|
762
|
+
const type = card.cardId.split('-')[1] || 'unknown'; // c-ws-... → ws, c-intro-... → intro, etc.
|
|
763
|
+
if (!byType.has(type)) {
|
|
764
|
+
byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
|
|
765
|
+
}
|
|
766
|
+
const entry = byType.get(type)!;
|
|
767
|
+
entry.total++;
|
|
768
|
+
if (card.score >= THRESHOLD) {
|
|
769
|
+
entry.wellIndicated++;
|
|
770
|
+
if (!encounteredIds.has(card.cardId)) entry.new++;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const elapsed = performance.now() - t0;
|
|
775
|
+
|
|
776
|
+
const result: CardSpaceDiagnosis = {
|
|
777
|
+
totalCards: allCardIds.length,
|
|
778
|
+
threshold: THRESHOLD,
|
|
779
|
+
wellIndicated: wellIndicatedIds.size,
|
|
780
|
+
encountered: encounteredIds.size,
|
|
781
|
+
wellIndicatedNew: wellIndicatedNew.length,
|
|
782
|
+
byType: Object.fromEntries(byType),
|
|
783
|
+
filterBreakdown,
|
|
784
|
+
elapsedMs: Math.round(elapsed),
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// Log to console
|
|
788
|
+
logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
|
|
789
|
+
logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
|
|
790
|
+
logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
|
|
791
|
+
logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
|
|
792
|
+
logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
|
|
793
|
+
logger.info(`[Pipeline:diagnose] By type:`);
|
|
794
|
+
for (const [type, counts] of byType) {
|
|
795
|
+
logger.info(
|
|
796
|
+
`[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
logger.info(`[Pipeline:diagnose] After each filter:`);
|
|
800
|
+
for (const fb of filterBreakdown) {
|
|
801
|
+
logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Diagnosis of the full card space for the current user.
|
|
811
|
+
*/
|
|
812
|
+
export interface CardSpaceDiagnosis {
|
|
813
|
+
totalCards: number;
|
|
814
|
+
threshold: number;
|
|
815
|
+
wellIndicated: number;
|
|
816
|
+
encountered: number;
|
|
817
|
+
wellIndicatedNew: number;
|
|
818
|
+
byType: Record<string, { total: number; wellIndicated: number; new: number }>;
|
|
819
|
+
filterBreakdown: Array<{ name: string; wellIndicated: number }>;
|
|
820
|
+
elapsedMs: number;
|
|
416
821
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
|
|
2
|
-
import { ContentNavigator, isGenerator, isFilter } from './index';
|
|
2
|
+
import { ContentNavigator, isGenerator, isFilter, Navigators } from './index';
|
|
3
3
|
import type { CardFilter } from './filters/types';
|
|
4
4
|
import { WeightedFilter } from './filters/WeightedFilter';
|
|
5
5
|
import type { CardGenerator } from './generators/types';
|
|
@@ -103,24 +103,29 @@ export class PipelineAssembler {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
//
|
|
106
|
+
// Always ensure ELO and SRS generators are present.
|
|
107
|
+
// Custom generators (e.g., prescribed) supplement but don't replace them.
|
|
108
|
+
const courseId = course.getCourseID();
|
|
109
|
+
const hasElo = generatorStrategies.some((s) => s.implementingClass === Navigators.ELO);
|
|
110
|
+
const hasSrs = generatorStrategies.some((s) => s.implementingClass === Navigators.SRS);
|
|
111
|
+
|
|
112
|
+
if (!hasElo) {
|
|
113
|
+
logger.debug('[PipelineAssembler] No ELO generator configured, adding default');
|
|
114
|
+
generatorStrategies.push(createDefaultEloStrategy(courseId));
|
|
115
|
+
}
|
|
116
|
+
if (!hasSrs) {
|
|
117
|
+
logger.debug('[PipelineAssembler] No SRS generator configured, adding default');
|
|
118
|
+
generatorStrategies.push(createDefaultSrsStrategy(courseId));
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
if (generatorStrategies.length === 0) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
} else {
|
|
116
|
-
warnings.push('No generator strategy found');
|
|
117
|
-
return {
|
|
118
|
-
pipeline: null,
|
|
119
|
-
generatorStrategies: [],
|
|
120
|
-
filterStrategies: [],
|
|
121
|
-
warnings,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
122
|
+
warnings.push('No generator strategy found');
|
|
123
|
+
return {
|
|
124
|
+
pipeline: null,
|
|
125
|
+
generatorStrategies: [],
|
|
126
|
+
filterStrategies: [],
|
|
127
|
+
warnings,
|
|
128
|
+
};
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
// Instantiate generators
|