@vue-skuilder/db 0.1.18 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/dist/{classroomDB-BgfrVb8d.d.ts → contentSource-BP9hznNV.d.ts} +220 -197
- package/dist/{classroomDB-CTOenngH.d.cts → contentSource-DsJadoBU.d.cts} +220 -197
- package/dist/core/index.d.cts +80 -6
- package/dist/core/index.d.ts +80 -6
- package/dist/core/index.js +735 -1560
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +708 -1539
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
- package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +8 -23
- package/dist/impl/couch/index.d.ts +8 -23
- package/dist/impl/couch/index.js +723 -1578
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +692 -1552
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +25 -8
- package/dist/impl/static/index.d.ts +25 -8
- package/dist/impl/static/index.js +700 -1400
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +688 -1393
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
- package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
- package/dist/index.d.cts +71 -63
- package/dist/index.d.ts +71 -63
- package/dist/index.js +1162 -1996
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1124 -1955
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -0
- package/dist/pouch/index.js.map +1 -1
- package/dist/pouch/index.mjs +3 -0
- package/dist/pouch/index.mjs.map +1 -1
- package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
- package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
- package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
- package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/navigators-architecture.md +115 -17
- package/package.json +4 -4
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/classroomDB.ts +5 -13
- package/src/core/interfaces/contentSource.ts +6 -66
- package/src/core/interfaces/courseDB.ts +15 -7
- package/src/core/interfaces/userDB.ts +32 -0
- package/src/core/navigators/Pipeline.ts +136 -52
- package/src/core/navigators/PipelineAssembler.ts +1 -1
- package/src/core/navigators/defaults.ts +84 -0
- package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +15 -29
- package/src/core/navigators/filters/index.ts +3 -0
- package/src/core/navigators/filters/inferredPreferenceStub.ts +107 -0
- package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +11 -37
- package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +12 -38
- package/src/core/navigators/filters/userGoalStub.ts +136 -0
- package/src/core/navigators/filters/userTagPreference.ts +217 -0
- package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
- package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
- package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
- package/src/core/navigators/generators/types.ts +1 -1
- package/src/core/navigators/index.ts +95 -91
- package/src/core/types/strategyState.ts +84 -0
- package/src/core/types/types-legacy.ts +2 -0
- package/src/impl/common/BaseUserDB.ts +74 -7
- package/src/impl/couch/adminDB.ts +1 -2
- package/src/impl/couch/classroomDB.ts +100 -103
- package/src/impl/couch/courseDB.ts +35 -91
- package/src/impl/couch/pouchdb-setup.ts +7 -0
- package/src/impl/static/StaticDataUnpacker.ts +50 -1
- package/src/impl/static/courseDB.ts +87 -37
- package/src/study/SessionController.ts +122 -202
- package/src/study/SourceMixer.ts +65 -0
- package/src/study/TagFilteredContentSource.ts +49 -92
- package/src/study/index.ts +1 -0
- package/src/study/services/CardHydrationService.ts +165 -81
- package/src/util/dataDirectory.ts +1 -1
- package/src/util/index.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
- package/tests/core/navigators/Pipeline.test.ts +6 -72
- package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
- package/tests/core/navigators/navigators.test.ts +118 -151
- package/docs/todo-pipeline-optimization.md +0 -117
- package/docs/todo-strategy-state-storage.md +0 -278
- package/src/core/navigators/hardcodedOrder.ts +0 -163
- package/src/util/tuiLogger.ts +0 -139
|
@@ -14,12 +14,8 @@ import {
|
|
|
14
14
|
|
|
15
15
|
import { CardRecord, CardHistory, CourseRegistrationDoc } from '@db/core';
|
|
16
16
|
import { Loggable } from '@db/util';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
function randomInt(min: number, max: number): number {
|
|
21
|
-
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
22
|
-
}
|
|
17
|
+
import { getCardOrigin } from '@db/core/navigators';
|
|
18
|
+
import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
23
19
|
|
|
24
20
|
export interface StudySessionRecord {
|
|
25
21
|
card: {
|
|
@@ -32,6 +28,7 @@ export interface StudySessionRecord {
|
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
import { DataLayerProvider } from '@db/core';
|
|
31
|
+
import { logger } from '@db/util/logger';
|
|
35
32
|
|
|
36
33
|
export type SessionAction =
|
|
37
34
|
| 'dismiss-success'
|
|
@@ -63,6 +60,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
63
60
|
private srsService: SrsService;
|
|
64
61
|
private eloService: EloService;
|
|
65
62
|
private hydrationService: CardHydrationService<TView>;
|
|
63
|
+
private mixer: SourceMixer;
|
|
66
64
|
|
|
67
65
|
private sources: StudyContentSource[];
|
|
68
66
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
@@ -95,25 +93,29 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
95
93
|
private _intervalHandle: NodeJS.Timeout;
|
|
96
94
|
|
|
97
95
|
/**
|
|
98
|
-
*
|
|
96
|
+
* @param sources - Array of content sources to mix for the session
|
|
97
|
+
* @param time - Session duration in seconds
|
|
98
|
+
* @param dataLayer - Data layer provider
|
|
99
|
+
* @param getViewComponent - Function to resolve view components
|
|
100
|
+
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
99
101
|
*/
|
|
100
102
|
constructor(
|
|
101
103
|
sources: StudyContentSource[],
|
|
102
104
|
time: number,
|
|
103
105
|
dataLayer: DataLayerProvider,
|
|
104
|
-
getViewComponent: (viewId: string) => TView
|
|
106
|
+
getViewComponent: (viewId: string) => TView,
|
|
107
|
+
mixer?: SourceMixer
|
|
105
108
|
) {
|
|
106
109
|
super();
|
|
107
110
|
|
|
111
|
+
this.mixer = mixer || new QuotaRoundRobinMixer();
|
|
108
112
|
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
109
113
|
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
110
114
|
|
|
111
115
|
this.hydrationService = new CardHydrationService<TView>(
|
|
112
116
|
getViewComponent,
|
|
113
117
|
(courseId: string) => dataLayer.getCourseDB(courseId),
|
|
114
|
-
() => this.
|
|
115
|
-
(item: StudySessionItem) => this.removeItemFromQueue(item),
|
|
116
|
-
() => this.hasAvailableCards()
|
|
118
|
+
() => this._getItemsToHydrate()
|
|
117
119
|
);
|
|
118
120
|
|
|
119
121
|
this.services = {
|
|
@@ -181,20 +183,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
public async prepareSession() {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
await this.getWeightedContent();
|
|
190
|
-
} else {
|
|
191
|
-
// Legacy path: separate calls for reviews and new cards
|
|
192
|
-
await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
|
|
193
|
-
}
|
|
194
|
-
} catch (e) {
|
|
195
|
-
this.error('Error preparing study session:', e);
|
|
186
|
+
// All content sources must implement getWeightedCards()
|
|
187
|
+
if (this.sources.some((s) => typeof s.getWeightedCards !== 'function')) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
'[SessionController] All content sources must implement getWeightedCards().'
|
|
190
|
+
);
|
|
196
191
|
}
|
|
197
192
|
|
|
193
|
+
await this.getWeightedContent();
|
|
198
194
|
await this.hydrationService.ensureHydratedCards();
|
|
199
195
|
|
|
200
196
|
this._intervalHandle = setInterval(() => {
|
|
@@ -240,20 +236,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
240
236
|
return items;
|
|
241
237
|
};
|
|
242
238
|
|
|
243
|
-
const extractHydratedItems = () => {
|
|
244
|
-
// We can't easily iterate the hydrated queue without dequeuing,
|
|
245
|
-
// so we'll just report the count via hydratedCache.count below
|
|
246
|
-
|
|
247
|
-
const items: any[] = [];
|
|
248
|
-
return items;
|
|
249
|
-
};
|
|
250
|
-
|
|
251
239
|
return {
|
|
252
240
|
api: {
|
|
253
241
|
mode: supportsWeightedCards ? 'weighted' : 'legacy',
|
|
254
242
|
description: supportsWeightedCards
|
|
255
243
|
? 'Using getWeightedCards() API with scored candidates'
|
|
256
|
-
: '
|
|
244
|
+
: 'ERROR: getWeightedCards() not a function.',
|
|
257
245
|
},
|
|
258
246
|
reviewQueue: {
|
|
259
247
|
length: this.reviewQ.length,
|
|
@@ -272,194 +260,119 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
272
260
|
},
|
|
273
261
|
hydratedCache: {
|
|
274
262
|
count: this.hydrationService.hydratedCount,
|
|
275
|
-
|
|
276
|
-
items: extractHydratedItems(),
|
|
263
|
+
cardIds: this.hydrationService.getHydratedCardIds(),
|
|
277
264
|
},
|
|
278
265
|
};
|
|
279
266
|
}
|
|
280
267
|
|
|
281
268
|
/**
|
|
282
|
-
* Fetch content using the
|
|
283
|
-
*
|
|
284
|
-
* This method uses getWeightedCards() to get scored candidates, then uses the
|
|
285
|
-
* scores to determine ordering. For reviews, we still need the full ScheduledCard
|
|
286
|
-
* data from getPendingReviews(), so we fetch both and use scores for ordering.
|
|
269
|
+
* Fetch content using the getWeightedCards API and mix across sources.
|
|
287
270
|
*
|
|
288
|
-
*
|
|
289
|
-
* 1.
|
|
290
|
-
* 2.
|
|
291
|
-
* 3.
|
|
292
|
-
* 4.
|
|
271
|
+
* This method:
|
|
272
|
+
* 1. Fetches weighted cards from each source
|
|
273
|
+
* 2. Fetches full review data (we need ScheduledCard fields for queue)
|
|
274
|
+
* 3. Uses SourceMixer to balance content across sources
|
|
275
|
+
* 4. Populates review and new card queues with mixed results
|
|
293
276
|
*/
|
|
294
277
|
private async getWeightedContent() {
|
|
295
278
|
const limit = 20; // Initial batch size per source
|
|
296
279
|
|
|
297
|
-
// Collect
|
|
298
|
-
const
|
|
299
|
-
const allReviews: (StudySessionReviewItem & ScheduledCard)[] = [];
|
|
300
|
-
const allNewCards: StudySessionNewItem[] = [];
|
|
280
|
+
// Collect batches from each source
|
|
281
|
+
const batches: SourceBatch[] = [];
|
|
301
282
|
|
|
302
|
-
for (
|
|
283
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
284
|
+
const source = this.sources[i];
|
|
303
285
|
try {
|
|
304
|
-
//
|
|
305
|
-
const
|
|
306
|
-
this.error(`Failed to get reviews for source:`, error);
|
|
307
|
-
return [];
|
|
308
|
-
});
|
|
309
|
-
allReviews.push(...reviews);
|
|
286
|
+
// Fetch weighted cards for mixing
|
|
287
|
+
const weighted = await source.getWeightedCards!(limit);
|
|
310
288
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
} else {
|
|
316
|
-
// Fallback: fetch new cards directly and assign score=1.0
|
|
317
|
-
const newCards = await source.getNewCards(limit);
|
|
318
|
-
allNewCards.push(...newCards);
|
|
319
|
-
|
|
320
|
-
// Create pseudo-weighted entries for ordering
|
|
321
|
-
allWeighted.push(
|
|
322
|
-
...newCards.map((c) => ({
|
|
323
|
-
cardId: c.cardID,
|
|
324
|
-
courseId: c.courseID,
|
|
325
|
-
score: 1.0,
|
|
326
|
-
provenance: [
|
|
327
|
-
{
|
|
328
|
-
strategy: 'legacy',
|
|
329
|
-
strategyName: 'Legacy Fallback',
|
|
330
|
-
strategyId: 'legacy-fallback',
|
|
331
|
-
action: 'generated' as const,
|
|
332
|
-
score: 1.0,
|
|
333
|
-
reason: 'Fallback to legacy getNewCards(), new card',
|
|
334
|
-
},
|
|
335
|
-
],
|
|
336
|
-
})),
|
|
337
|
-
...reviews.map((r) => ({
|
|
338
|
-
cardId: r.cardID,
|
|
339
|
-
courseId: r.courseID,
|
|
340
|
-
score: 1.0,
|
|
341
|
-
provenance: [
|
|
342
|
-
{
|
|
343
|
-
strategy: 'legacy',
|
|
344
|
-
strategyName: 'Legacy Fallback',
|
|
345
|
-
strategyId: 'legacy-fallback',
|
|
346
|
-
action: 'generated' as const,
|
|
347
|
-
score: 1.0,
|
|
348
|
-
reason: 'Fallback to legacy getPendingReviews(), review',
|
|
349
|
-
},
|
|
350
|
-
],
|
|
351
|
-
}))
|
|
352
|
-
);
|
|
353
|
-
}
|
|
289
|
+
batches.push({
|
|
290
|
+
sourceIndex: i,
|
|
291
|
+
weighted,
|
|
292
|
+
});
|
|
354
293
|
} catch (error) {
|
|
355
|
-
this.error(`Failed to get content from source:`, error);
|
|
294
|
+
this.error(`Failed to get content from source ${i}:`, error);
|
|
295
|
+
// Re-throw if this is the only source - we can't proceed without any content
|
|
296
|
+
if (this.sources.length === 1) {
|
|
297
|
+
throw new Error(`Cannot start session: failed to load content from source ${i}`);
|
|
298
|
+
}
|
|
356
299
|
}
|
|
357
300
|
}
|
|
358
301
|
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
302
|
+
// Verify we got content from at least one source
|
|
303
|
+
if (batches.length === 0) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Cannot start session: failed to load content from all ${this.sources.length} source(s). ` +
|
|
306
|
+
`Check logs for details.`
|
|
307
|
+
);
|
|
364
308
|
}
|
|
365
309
|
|
|
366
|
-
//
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
// Add reviews to queue in score order
|
|
374
|
-
let report = 'Weighted content session created with:\n';
|
|
375
|
-
for (const { review, score } of scoredReviews) {
|
|
376
|
-
this.reviewQ.add(review, review.cardID);
|
|
377
|
-
report += `Review: ${review.courseID}::${review.cardID} (score: ${score.toFixed(2)})\n`;
|
|
378
|
-
}
|
|
310
|
+
// Mix weighted cards across sources using configured strategy
|
|
311
|
+
const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
|
|
312
|
+
|
|
313
|
+
// Split mixed results by card origin
|
|
314
|
+
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'review');
|
|
315
|
+
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'new');
|
|
379
316
|
|
|
380
|
-
|
|
381
|
-
const newCardWeighted = allWeighted
|
|
382
|
-
.filter((w) => getCardOrigin(w) === 'new')
|
|
383
|
-
.sort((a, b) => b.score - a.score);
|
|
317
|
+
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
384
318
|
|
|
385
|
-
//
|
|
386
|
-
|
|
319
|
+
// Populate review queue from mixed results (already sorted by mixer)
|
|
320
|
+
let report = 'Mixed content session created with:\n';
|
|
321
|
+
for (const w of reviewWeighted) {
|
|
322
|
+
const reviewItem: StudySessionReviewItem = {
|
|
323
|
+
cardID: w.cardId,
|
|
324
|
+
courseID: w.courseId,
|
|
325
|
+
contentSourceType: 'course',
|
|
326
|
+
contentSourceID: w.courseId,
|
|
327
|
+
reviewID: w.reviewID!,
|
|
328
|
+
status: 'review',
|
|
329
|
+
};
|
|
330
|
+
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
331
|
+
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})\n`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Populate new card queue from mixed results (already sorted by mixer)
|
|
335
|
+
for (const w of newWeighted) {
|
|
387
336
|
const newItem: StudySessionNewItem = {
|
|
388
|
-
cardID:
|
|
389
|
-
courseID:
|
|
337
|
+
cardID: w.cardId,
|
|
338
|
+
courseID: w.courseId,
|
|
390
339
|
contentSourceType: 'course',
|
|
391
|
-
contentSourceID:
|
|
340
|
+
contentSourceID: w.courseId,
|
|
392
341
|
status: 'new',
|
|
393
342
|
};
|
|
394
|
-
this.newQ.add(newItem,
|
|
395
|
-
report += `New: ${
|
|
343
|
+
this.newQ.add(newItem, newItem.cardID);
|
|
344
|
+
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})\n`;
|
|
396
345
|
}
|
|
397
346
|
|
|
398
347
|
this.log(report);
|
|
399
348
|
}
|
|
400
349
|
|
|
401
350
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
351
|
+
* Returns items that should be pre-hydrated.
|
|
352
|
+
* Deterministic: top N items from each queue to ensure coverage.
|
|
353
|
+
* Failed queue items will typically already be hydrated (from initial render).
|
|
404
354
|
*/
|
|
405
|
-
private
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
c.getPendingReviews().catch((error) => {
|
|
409
|
-
this.error(`Failed to get reviews for source ${c}:`, error);
|
|
410
|
-
return [];
|
|
411
|
-
})
|
|
412
|
-
)
|
|
413
|
-
);
|
|
414
|
-
|
|
415
|
-
const dueCards: (StudySessionReviewItem & ScheduledCard)[] = [];
|
|
355
|
+
private _getItemsToHydrate(): StudySessionItem[] {
|
|
356
|
+
const items: StudySessionItem[] = [];
|
|
357
|
+
const ITEMS_PER_QUEUE = 2;
|
|
416
358
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
} else {
|
|
426
|
-
dueCards.push(source.shift()!);
|
|
427
|
-
}
|
|
359
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.reviewQ.length); i++) {
|
|
360
|
+
items.push(this.reviewQ.peek(i));
|
|
361
|
+
}
|
|
362
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
|
|
363
|
+
items.push(this.newQ.peek(i));
|
|
364
|
+
}
|
|
365
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.failedQ.length); i++) {
|
|
366
|
+
items.push(this.failedQ.peek(i));
|
|
428
367
|
}
|
|
429
368
|
|
|
430
|
-
|
|
431
|
-
this.reviewQ.addAll(dueCards, (c) => c.cardID);
|
|
432
|
-
report += dueCards.map((card) => `Card ${card.courseID}::${card.cardID} `).join('\n');
|
|
433
|
-
this.log(report);
|
|
369
|
+
return items;
|
|
434
370
|
}
|
|
435
371
|
|
|
436
372
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
373
|
+
* Selects the next item to present to the user.
|
|
374
|
+
* Nondeterministic: uses probability to balance between queues based on session state.
|
|
439
375
|
*/
|
|
440
|
-
private async getNewCards(n: number = 10) {
|
|
441
|
-
const perCourse = Math.ceil(n / this.sources.length);
|
|
442
|
-
const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
|
|
443
|
-
|
|
444
|
-
// [ ] is this a noop?
|
|
445
|
-
newContent.forEach((newContentFromSource) => {
|
|
446
|
-
newContentFromSource.filter((c) => {
|
|
447
|
-
return this._sessionRecord.find((record) => record.card.card_id === c.cardID) === undefined;
|
|
448
|
-
});
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
while (n > 0 && newContent.some((nc) => nc.length > 0)) {
|
|
452
|
-
for (let i = 0; i < newContent.length; i++) {
|
|
453
|
-
if (newContent[i].length > 0) {
|
|
454
|
-
const item = newContent[i].splice(0, 1)[0];
|
|
455
|
-
this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
|
|
456
|
-
this.newQ.add(item, item.cardID);
|
|
457
|
-
n--;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
376
|
private _selectNextItemToHydrate(): StudySessionItem | null {
|
|
464
377
|
const choice = Math.random();
|
|
465
378
|
let newBound: number = 0.1;
|
|
@@ -543,22 +456,28 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
543
456
|
return null;
|
|
544
457
|
}
|
|
545
458
|
|
|
546
|
-
|
|
459
|
+
// Get what SessionController thinks should be next
|
|
460
|
+
const nextItem = this._selectNextItemToHydrate();
|
|
461
|
+
if (!nextItem) {
|
|
462
|
+
this._currentCard = null;
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Look up in hydration cache
|
|
467
|
+
let card = this.hydrationService.getHydratedCard(nextItem.cardID);
|
|
547
468
|
|
|
548
|
-
// If
|
|
549
|
-
if (!card
|
|
550
|
-
card = await this.hydrationService.
|
|
469
|
+
// If not ready, wait for it
|
|
470
|
+
if (!card) {
|
|
471
|
+
card = await this.hydrationService.waitForCard(nextItem.cardID);
|
|
551
472
|
}
|
|
552
473
|
|
|
474
|
+
// Remove from source queue now that we're consuming it
|
|
475
|
+
this.removeItemFromQueue(nextItem);
|
|
476
|
+
|
|
553
477
|
// Trigger background hydration to maintain cache (async, non-blocking)
|
|
554
478
|
await this.hydrationService.ensureHydratedCards();
|
|
555
479
|
|
|
556
|
-
|
|
557
|
-
this._currentCard = card;
|
|
558
|
-
} else {
|
|
559
|
-
this._currentCard = null;
|
|
560
|
-
}
|
|
561
|
-
|
|
480
|
+
this._currentCard = card;
|
|
562
481
|
return card;
|
|
563
482
|
}
|
|
564
483
|
|
|
@@ -617,9 +536,11 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
617
536
|
// }
|
|
618
537
|
|
|
619
538
|
if (action === 'dismiss-success') {
|
|
539
|
+
// Remove from hydration cache to free memory
|
|
540
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
620
541
|
// schedule a review - currently done in Study.vue
|
|
621
542
|
} else if (action === 'marked-failed') {
|
|
622
|
-
|
|
543
|
+
// Card stays in hydration cache for re-use (no removeCard call)
|
|
623
544
|
|
|
624
545
|
let failedItem: StudySessionFailedItem;
|
|
625
546
|
|
|
@@ -644,26 +565,25 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
644
565
|
|
|
645
566
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
646
567
|
} else if (action === 'dismiss-error') {
|
|
647
|
-
//
|
|
568
|
+
// Remove from cache on error as well
|
|
569
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
648
570
|
} else if (action === 'dismiss-failed') {
|
|
649
|
-
//
|
|
571
|
+
// Remove from cache - card has been fully processed after failure cleanup
|
|
572
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
650
573
|
}
|
|
651
574
|
}
|
|
652
575
|
}
|
|
653
576
|
|
|
654
|
-
private hasAvailableCards(): boolean {
|
|
655
|
-
return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
577
|
/**
|
|
659
|
-
*
|
|
578
|
+
* Remove an item from its source queue after consumption by nextCard().
|
|
660
579
|
*/
|
|
661
580
|
private removeItemFromQueue(item: StudySessionItem): void {
|
|
662
|
-
|
|
581
|
+
// Check each queue - item should be at the front of one of them
|
|
582
|
+
if (this.reviewQ.peek(0)?.cardID === item.cardID) {
|
|
663
583
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
664
|
-
} else if (this.newQ.peek(0) === item) {
|
|
584
|
+
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
665
585
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
666
|
-
} else {
|
|
586
|
+
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
667
587
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
668
588
|
}
|
|
669
589
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { WeightedCard } from '@db/core/navigators';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents a batch of content fetched from a single StudyContentSource.
|
|
5
|
+
*/
|
|
6
|
+
export interface SourceBatch {
|
|
7
|
+
sourceIndex: number;
|
|
8
|
+
weighted: WeightedCard[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Strategy interface for mixing content from multiple sources into a unified
|
|
13
|
+
* set of weighted candidates.
|
|
14
|
+
*
|
|
15
|
+
* Different implementations can provide different balancing strategies:
|
|
16
|
+
* - QuotaRoundRobinMixer: Equal representation per source
|
|
17
|
+
* - MinMaxNormalizingMixer: Score normalization then global sort
|
|
18
|
+
* - PercentileBucketMixer: Bucketed round-robin
|
|
19
|
+
* etc.
|
|
20
|
+
*/
|
|
21
|
+
export interface SourceMixer {
|
|
22
|
+
/**
|
|
23
|
+
* Mix weighted cards from multiple sources into a unified, ordered list.
|
|
24
|
+
*
|
|
25
|
+
* @param batches - Content batches from each source
|
|
26
|
+
* @param limit - Target number of cards to return
|
|
27
|
+
* @returns Mixed and ordered weighted cards
|
|
28
|
+
*/
|
|
29
|
+
mix(batches: SourceBatch[], limit: number): WeightedCard[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Simple quota-based mixer: allocates equal representation to each source,
|
|
34
|
+
* taking the top-N cards by score from each.
|
|
35
|
+
*
|
|
36
|
+
* Guarantees balanced representation across sources regardless of absolute
|
|
37
|
+
* score differences. A low-scoring source gets the same quota as a high-scoring
|
|
38
|
+
* source.
|
|
39
|
+
*
|
|
40
|
+
* This is the KISS approach - simple, predictable, and fair in terms of
|
|
41
|
+
* source representation (though not necessarily optimal in terms of absolute
|
|
42
|
+
* card quality).
|
|
43
|
+
*/
|
|
44
|
+
export class QuotaRoundRobinMixer implements SourceMixer {
|
|
45
|
+
mix(batches: SourceBatch[], limit: number): WeightedCard[] {
|
|
46
|
+
if (batches.length === 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const quotaPerSource = Math.ceil(limit / batches.length);
|
|
51
|
+
const mixed: WeightedCard[] = [];
|
|
52
|
+
|
|
53
|
+
for (const batch of batches) {
|
|
54
|
+
// Sort this source's cards by score descending
|
|
55
|
+
const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
|
|
56
|
+
|
|
57
|
+
// Take top quotaPerSource from this source
|
|
58
|
+
const topFromSource = sortedBatch.slice(0, quotaPerSource);
|
|
59
|
+
mixed.push(...topFromSource);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Sort the mixed result by score descending and return up to limit
|
|
63
|
+
return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
64
|
+
}
|
|
65
|
+
}
|