@vue-skuilder/db 0.1.31 → 0.1.32-b
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.js +17 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +17 -4
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.d.cts +2 -0
- package/dist/impl/couch/index.d.ts +2 -0
- package/dist/impl/couch/index.js +17 -4
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +17 -4
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +2 -0
- package/dist/impl/static/index.d.ts +2 -0
- package/dist/impl/static/index.js +17 -4
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +17 -4
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +74 -4
- package/dist/index.d.ts +74 -4
- package/dist/index.js +139 -21
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +139 -21
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/core/navigators/Pipeline.ts +11 -3
- package/src/core/navigators/filters/hierarchyDefinition.ts +4 -0
- package/src/core/navigators/filters/relativePriority.ts +7 -1
- package/src/impl/couch/courseDB.ts +10 -0
- package/src/impl/static/courseDB.ts +12 -0
- package/src/study/SessionController.ts +210 -20
- package/src/study/services/ResponseProcessor.ts +22 -2
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.1.
|
|
7
|
+
"version": "0.1.32-b",
|
|
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.
|
|
51
|
+
"@vue-skuilder/common": "0.1.32-b",
|
|
52
52
|
"cross-fetch": "^4.1.0",
|
|
53
53
|
"moment": "^2.29.4",
|
|
54
54
|
"pouchdb": "^9.0.0",
|
|
@@ -39,6 +39,13 @@ export interface ReplanHints {
|
|
|
39
39
|
excludeTags?: string[];
|
|
40
40
|
/** Remove these specific card IDs from results. */
|
|
41
41
|
excludeCards?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Debugging label threaded from the replan requester.
|
|
44
|
+
* Attached to provenance entries so card scoring history
|
|
45
|
+
* can be traced back to the originating event.
|
|
46
|
+
* Prefixed with `_` to signal it's metadata, not a scoring hint.
|
|
47
|
+
*/
|
|
48
|
+
_label?: string;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/**
|
|
@@ -544,7 +551,7 @@ export class Pipeline extends ContentNavigator {
|
|
|
544
551
|
card.provenance.push({
|
|
545
552
|
strategy: 'ephemeralHint',
|
|
546
553
|
strategyId: 'ephemeral-hint',
|
|
547
|
-
strategyName: 'Replan Hint',
|
|
554
|
+
strategyName: hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint',
|
|
548
555
|
action: 'boosted',
|
|
549
556
|
score: card.score,
|
|
550
557
|
reason: `boostTag ${pattern} ×${factor}`,
|
|
@@ -561,7 +568,7 @@ export class Pipeline extends ContentNavigator {
|
|
|
561
568
|
card.provenance.push({
|
|
562
569
|
strategy: 'ephemeralHint',
|
|
563
570
|
strategyId: 'ephemeral-hint',
|
|
564
|
-
strategyName: 'Replan Hint',
|
|
571
|
+
strategyName: hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint',
|
|
565
572
|
action: 'boosted',
|
|
566
573
|
score: card.score,
|
|
567
574
|
reason: `boostCard ${pattern} ×${factor}`,
|
|
@@ -573,6 +580,7 @@ export class Pipeline extends ContentNavigator {
|
|
|
573
580
|
|
|
574
581
|
// 3. Require — inject from the full pool if not already present
|
|
575
582
|
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
583
|
+
const hintLabel = hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint';
|
|
576
584
|
const inject = (card: WeightedCard, reason: string) => {
|
|
577
585
|
if (!cardIds.has(card.cardId)) {
|
|
578
586
|
// Give required cards a floor score so they sort above zero-score filler
|
|
@@ -585,7 +593,7 @@ export class Pipeline extends ContentNavigator {
|
|
|
585
593
|
{
|
|
586
594
|
strategy: 'ephemeralHint',
|
|
587
595
|
strategyId: 'ephemeral-hint',
|
|
588
|
-
strategyName:
|
|
596
|
+
strategyName: hintLabel,
|
|
589
597
|
action: 'boosted',
|
|
590
598
|
score: floorScore,
|
|
591
599
|
reason,
|
|
@@ -5,6 +5,7 @@ import type { WeightedCard } from '../index';
|
|
|
5
5
|
import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
|
|
6
6
|
import type { CardFilter, FilterContext } from './types';
|
|
7
7
|
import { toCourseElo } from '@vue-skuilder/common';
|
|
8
|
+
import { logger } from '../../../util/logger';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* A single prerequisite requirement for a tag.
|
|
@@ -286,6 +287,9 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
|
|
|
286
287
|
finalScore *= maxBoost;
|
|
287
288
|
action = 'boosted';
|
|
288
289
|
finalReason = `${reason} | preReqBoost ×${maxBoost.toFixed(2)} for ${boostedPrereqs.join(', ')}`;
|
|
290
|
+
logger.info(
|
|
291
|
+
`[HierarchyDefinition] preReqBoost ×${maxBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedPrereqs.join(', ')}] (score: ${card.score.toFixed(3)} → ${finalScore.toFixed(3)})`
|
|
292
|
+
);
|
|
289
293
|
}
|
|
290
294
|
}
|
|
291
295
|
|
|
@@ -198,7 +198,13 @@ export default class RelativePriorityNavigator extends ContentNavigator implemen
|
|
|
198
198
|
const cardTags = card.tags ?? [];
|
|
199
199
|
const priority = this.computeCardPriority(cardTags);
|
|
200
200
|
const boostFactor = this.computeBoostFactor(priority);
|
|
201
|
-
|
|
201
|
+
// No upper clamp — scores may exceed 1.0 intentionally.
|
|
202
|
+
// Scores are only used for relative ordering within a pipeline run,
|
|
203
|
+
// so absolute magnitude doesn't matter. Clamping to 1.0 here collapsed
|
|
204
|
+
// differentiation when GPC preReqBoosts and priority boosts compounded
|
|
205
|
+
// (e.g. 0.96 × 2.5 × 1.24 → 2.98, previously crushed back to 1.0).
|
|
206
|
+
// Floor of 0 is kept: negative scores have no meaning.
|
|
207
|
+
const finalScore = Math.max(0, card.score * boostFactor);
|
|
202
208
|
|
|
203
209
|
// Determine action based on boost factor
|
|
204
210
|
const action = boostFactor > 1.0 ? 'boosted' : boostFactor < 1.0 ? 'penalized' : 'passed';
|
|
@@ -650,11 +650,21 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
650
650
|
* @param limit - Maximum number of cards to return
|
|
651
651
|
* @returns Cards sorted by score descending
|
|
652
652
|
*/
|
|
653
|
+
private _pendingHints: Record<string, unknown> | null = null;
|
|
654
|
+
|
|
655
|
+
public setEphemeralHints(hints: Record<string, unknown>): void {
|
|
656
|
+
this._pendingHints = hints;
|
|
657
|
+
}
|
|
658
|
+
|
|
653
659
|
public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
654
660
|
const u = await this._getCurrentUser();
|
|
655
661
|
|
|
656
662
|
try {
|
|
657
663
|
const navigator = await this.createNavigator(u);
|
|
664
|
+
if (this._pendingHints) {
|
|
665
|
+
navigator.setEphemeralHints(this._pendingHints);
|
|
666
|
+
this._pendingHints = null;
|
|
667
|
+
}
|
|
658
668
|
return navigator.getWeightedCards(limit);
|
|
659
669
|
} catch (e) {
|
|
660
670
|
logger.error(`[courseDB] Error getting weighted cards: ${e}`);
|
|
@@ -442,9 +442,21 @@ export class StaticCourseDB implements CourseDBInterface {
|
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
// Study Content Source implementation
|
|
445
|
+
|
|
446
|
+
private _pendingHints: Record<string, unknown> | null = null;
|
|
447
|
+
|
|
448
|
+
setEphemeralHints(hints: Record<string, unknown>): void {
|
|
449
|
+
this._pendingHints = hints;
|
|
450
|
+
}
|
|
451
|
+
|
|
445
452
|
async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
446
453
|
try {
|
|
447
454
|
const navigator = await this.createNavigator(this.userDB);
|
|
455
|
+
// Forward any pending hints to the Pipeline
|
|
456
|
+
if (this._pendingHints) {
|
|
457
|
+
navigator.setEphemeralHints(this._pendingHints);
|
|
458
|
+
this._pendingHints = null;
|
|
459
|
+
}
|
|
448
460
|
return navigator.getWeightedCards(limit);
|
|
449
461
|
} catch (e) {
|
|
450
462
|
logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
|
|
@@ -20,6 +20,35 @@ import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
|
20
20
|
import { captureMixerRun } from './MixerDebugger';
|
|
21
21
|
import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Options for requesting a mid-session replan.
|
|
25
|
+
*
|
|
26
|
+
* All fields are optional — callers can pass just the fields they need.
|
|
27
|
+
* When omitted, defaults match the existing behaviour (full 20-card
|
|
28
|
+
* replace with no hints).
|
|
29
|
+
*/
|
|
30
|
+
export interface ReplanOptions {
|
|
31
|
+
/** Scoring hints forwarded to the pipeline (boost/exclude/require). */
|
|
32
|
+
hints?: Record<string, unknown>;
|
|
33
|
+
/**
|
|
34
|
+
* Maximum number of new cards to return from the pipeline.
|
|
35
|
+
* Default: 20 (the standard session batch size).
|
|
36
|
+
*/
|
|
37
|
+
limit?: number;
|
|
38
|
+
/**
|
|
39
|
+
* How to integrate the new cards into the existing newQ.
|
|
40
|
+
* - `'replace'` (default): atomically swap the entire newQ.
|
|
41
|
+
* - `'merge'`: insert new cards at the front, keeping existing cards.
|
|
42
|
+
*/
|
|
43
|
+
mode?: 'replace' | 'merge';
|
|
44
|
+
/**
|
|
45
|
+
* Human-readable label for debugging / provenance.
|
|
46
|
+
* Appears in console logs and in card provenance entries created
|
|
47
|
+
* by ephemeral hint application.
|
|
48
|
+
*/
|
|
49
|
+
label?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
export interface StudySessionRecord {
|
|
24
53
|
card: {
|
|
25
54
|
course_id: string;
|
|
@@ -44,6 +73,14 @@ export interface ResponseResult {
|
|
|
44
73
|
nextCardAction: Exclude<SessionAction, 'dismiss-error'> | 'none';
|
|
45
74
|
shouldLoadNextCard: boolean;
|
|
46
75
|
|
|
76
|
+
/**
|
|
77
|
+
* When true, the card requested deferred advancement via `deferAdvance`.
|
|
78
|
+
* The record was logged and ELO updated, but navigation was suppressed.
|
|
79
|
+
* StudySession should stash `nextCardAction` and wait for a
|
|
80
|
+
* `ready-to-advance` event from the card before calling `nextCard()`.
|
|
81
|
+
*/
|
|
82
|
+
deferred?: boolean;
|
|
83
|
+
|
|
47
84
|
// UI Data (let view decide how to render)
|
|
48
85
|
isCorrect: boolean;
|
|
49
86
|
performanceScore?: number; // for shadow color calculation
|
|
@@ -67,6 +104,13 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
67
104
|
private dataLayer: DataLayerProvider;
|
|
68
105
|
private courseNameCache: Map<string, string> = new Map();
|
|
69
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Default pipeline batch size for new-card planning.
|
|
109
|
+
* Set via constructor options; falls back to 20 when not specified.
|
|
110
|
+
* Individual replans can override via `ReplanOptions.limit`.
|
|
111
|
+
*/
|
|
112
|
+
private _defaultBatchLimit: number = 20;
|
|
113
|
+
|
|
70
114
|
private sources: StudyContentSource[];
|
|
71
115
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
72
116
|
private _sessionRecord: StudySessionRecord[] = [];
|
|
@@ -96,6 +140,22 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
96
140
|
*/
|
|
97
141
|
private _wellIndicatedRemaining: number = 0;
|
|
98
142
|
|
|
143
|
+
/**
|
|
144
|
+
* When true, suppresses the quality-based auto-replan trigger in
|
|
145
|
+
* nextCard(). Set after a burst replan (small limit) to prevent the
|
|
146
|
+
* auto-replan from clobbering the burst cards before they're consumed.
|
|
147
|
+
* Cleared when the depletion-triggered replan fires (newQ exhausted).
|
|
148
|
+
*/
|
|
149
|
+
private _suppressQualityReplan: boolean = false;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Guards against infinite depletion-triggered replans. Set to true
|
|
153
|
+
* when a depletion replan fires; cleared when a replan produces
|
|
154
|
+
* content (newQ.length > 0 after replan) or when an explicit
|
|
155
|
+
* (non-auto) replan is requested.
|
|
156
|
+
*/
|
|
157
|
+
private _depletionReplanAttempted: boolean = false;
|
|
158
|
+
|
|
99
159
|
private startTime: Date;
|
|
100
160
|
private endTime: Date;
|
|
101
161
|
private _secondsRemaining: number;
|
|
@@ -121,13 +181,18 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
121
181
|
* @param dataLayer - Data layer provider
|
|
122
182
|
* @param getViewComponent - Function to resolve view components
|
|
123
183
|
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
184
|
+
* @param options - Optional session-level configuration
|
|
185
|
+
* @param options.defaultBatchLimit - Default pipeline batch size (default: 20).
|
|
186
|
+
* Smaller values for newer users cause more frequent replans, keeping plans
|
|
187
|
+
* aligned with rapidly-changing user state.
|
|
124
188
|
*/
|
|
125
189
|
constructor(
|
|
126
190
|
sources: StudyContentSource[],
|
|
127
191
|
time: number,
|
|
128
192
|
dataLayer: DataLayerProvider,
|
|
129
193
|
getViewComponent: (viewId: string) => TView,
|
|
130
|
-
mixer?: SourceMixer
|
|
194
|
+
mixer?: SourceMixer,
|
|
195
|
+
options?: { defaultBatchLimit?: number }
|
|
131
196
|
) {
|
|
132
197
|
super();
|
|
133
198
|
|
|
@@ -151,9 +216,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
151
216
|
this._secondsRemaining = time;
|
|
152
217
|
this.endTime = new Date(this.startTime.valueOf() + 1000 * this._secondsRemaining);
|
|
153
218
|
|
|
219
|
+
if (options?.defaultBatchLimit !== undefined) {
|
|
220
|
+
this._defaultBatchLimit = options.defaultBatchLimit;
|
|
221
|
+
}
|
|
222
|
+
|
|
154
223
|
this.log(`Session constructed:
|
|
155
224
|
startTime: ${this.startTime}
|
|
156
|
-
endTime: ${this.endTime}
|
|
225
|
+
endTime: ${this.endTime}
|
|
226
|
+
defaultBatchLimit: ${this._defaultBatchLimit}`);
|
|
157
227
|
}
|
|
158
228
|
|
|
159
229
|
private tick() {
|
|
@@ -246,22 +316,39 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
246
316
|
* Typical trigger: application-level code (e.g. after a GPC intro completion)
|
|
247
317
|
* calls this to ensure newly-unlocked content appears in the session.
|
|
248
318
|
*/
|
|
249
|
-
public async requestReplan(
|
|
319
|
+
public async requestReplan(options?: ReplanOptions | Record<string, unknown>): Promise<void> {
|
|
320
|
+
// Normalise: bare hints object (legacy callers) → ReplanOptions wrapper
|
|
321
|
+
const opts = this.normalizeReplanOptions(options);
|
|
322
|
+
|
|
323
|
+
// Explicit (non-auto) replans clear the depletion guard — the caller
|
|
324
|
+
// is providing fresh intent that may change pipeline results.
|
|
325
|
+
if (opts.hints || opts.label || opts.limit) {
|
|
326
|
+
this._depletionReplanAttempted = false;
|
|
327
|
+
}
|
|
328
|
+
|
|
250
329
|
if (this._replanPromise) {
|
|
251
330
|
this.log('Replan already in progress, awaiting existing replan');
|
|
252
331
|
return this._replanPromise;
|
|
253
332
|
}
|
|
254
333
|
|
|
255
|
-
// Forward hints to all sources
|
|
256
|
-
if (hints) {
|
|
334
|
+
// Forward hints to all sources (CourseDB stashes them, Pipeline consumes them)
|
|
335
|
+
if (opts.hints) {
|
|
336
|
+
// Thread label into hints so Pipeline can attach it to provenance
|
|
337
|
+
const hintsWithLabel = opts.label
|
|
338
|
+
? { ...opts.hints, _label: opts.label }
|
|
339
|
+
: opts.hints;
|
|
257
340
|
for (const source of this.sources) {
|
|
258
|
-
|
|
259
|
-
source.setEphemeralHints?.(hints);
|
|
341
|
+
source.setEphemeralHints?.(hintsWithLabel);
|
|
260
342
|
}
|
|
261
343
|
}
|
|
262
344
|
|
|
263
|
-
|
|
264
|
-
this.
|
|
345
|
+
const labelTag = opts.label ? ` [${opts.label}]` : '';
|
|
346
|
+
this.log(
|
|
347
|
+
`Mid-session replan requested${labelTag}` +
|
|
348
|
+
` (limit: ${opts.limit ?? 'default'}, mode: ${opts.mode ?? 'replace'}` +
|
|
349
|
+
`${opts.hints ? ', with hints' : ''})`
|
|
350
|
+
);
|
|
351
|
+
this._replanPromise = this._executeReplan(opts);
|
|
265
352
|
|
|
266
353
|
try {
|
|
267
354
|
await this._replanPromise;
|
|
@@ -270,6 +357,28 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
270
357
|
}
|
|
271
358
|
}
|
|
272
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Normalise the requestReplan argument. Accepts either a ReplanOptions
|
|
362
|
+
* object (new API) or a plain Record<string, unknown> (legacy callers
|
|
363
|
+
* that passed hints directly). Distinguishes the two by checking for
|
|
364
|
+
* the presence of ReplanOptions-specific keys.
|
|
365
|
+
*/
|
|
366
|
+
private normalizeReplanOptions(
|
|
367
|
+
input?: ReplanOptions | Record<string, unknown>
|
|
368
|
+
): ReplanOptions {
|
|
369
|
+
if (!input) return {};
|
|
370
|
+
|
|
371
|
+
// If the input has any ReplanOptions-specific key, treat it as ReplanOptions
|
|
372
|
+
const replanKeys = ['hints', 'limit', 'mode', 'label'];
|
|
373
|
+
const inputKeys = Object.keys(input);
|
|
374
|
+
if (inputKeys.some((k) => replanKeys.includes(k))) {
|
|
375
|
+
return input as ReplanOptions;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Otherwise treat as legacy bare-hints object
|
|
379
|
+
return { hints: input as Record<string, unknown> };
|
|
380
|
+
}
|
|
381
|
+
|
|
273
382
|
/** Minimum well-indicated cards before an additive retry is attempted */
|
|
274
383
|
private static readonly MIN_WELL_INDICATED = 5;
|
|
275
384
|
|
|
@@ -290,18 +399,43 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
290
399
|
* pass all hierarchy filters, one additive retry is attempted — merging
|
|
291
400
|
* any new high-quality candidates into the front of the queue.
|
|
292
401
|
*/
|
|
293
|
-
private async _executeReplan(): Promise<void> {
|
|
294
|
-
const
|
|
402
|
+
private async _executeReplan(opts: ReplanOptions = {}): Promise<void> {
|
|
403
|
+
const limit = opts.limit;
|
|
404
|
+
const mode = opts.mode ?? 'replace';
|
|
405
|
+
|
|
406
|
+
const wellIndicated = await this.getWeightedContent({
|
|
407
|
+
replan: true,
|
|
408
|
+
additive: mode === 'merge',
|
|
409
|
+
limit,
|
|
410
|
+
});
|
|
295
411
|
this._wellIndicatedRemaining = wellIndicated;
|
|
296
412
|
|
|
413
|
+
// Burst replan: suppress quality-based auto-replan so the background
|
|
414
|
+
// replan doesn't clobber the small hinted queue before it's consumed.
|
|
415
|
+
// The depletion trigger (newQ empty) takes over instead.
|
|
416
|
+
if (limit !== undefined && limit < this._defaultBatchLimit) {
|
|
417
|
+
this._suppressQualityReplan = true;
|
|
418
|
+
this.log(`[Replan] Burst mode (limit=${limit}): suppressing quality-based auto-replan`);
|
|
419
|
+
} else {
|
|
420
|
+
// Normal or auto-replan — clear the burst suppression flag
|
|
421
|
+
this._suppressQualityReplan = false;
|
|
422
|
+
}
|
|
423
|
+
|
|
297
424
|
if (wellIndicated >= 0 && wellIndicated < SessionController.MIN_WELL_INDICATED) {
|
|
298
425
|
this.log(
|
|
299
426
|
`[Replan] Only ${wellIndicated}/${SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
|
|
300
427
|
);
|
|
301
428
|
}
|
|
302
429
|
|
|
430
|
+
// If the replan produced content, clear the depletion guard so future
|
|
431
|
+
// depletions can trigger fresh replans.
|
|
432
|
+
if (this.newQ.length > 0) {
|
|
433
|
+
this._depletionReplanAttempted = false;
|
|
434
|
+
}
|
|
435
|
+
|
|
303
436
|
await this.hydrationService.ensureHydratedCards();
|
|
304
|
-
|
|
437
|
+
const labelTag = opts.label ? ` [${opts.label}]` : '';
|
|
438
|
+
this.log(`Replan complete${labelTag}: newQ now has ${this.newQ.length} cards (mode=${mode})`);
|
|
305
439
|
|
|
306
440
|
// Snapshot queue state for debugging
|
|
307
441
|
snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
@@ -373,6 +507,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
373
507
|
},
|
|
374
508
|
replan: {
|
|
375
509
|
inProgress: this._replanPromise !== null,
|
|
510
|
+
suppressQualityReplan: this._suppressQualityReplan,
|
|
511
|
+
defaultBatchLimit: this._defaultBatchLimit,
|
|
376
512
|
},
|
|
377
513
|
};
|
|
378
514
|
}
|
|
@@ -397,10 +533,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
397
533
|
* @returns Number of "well-indicated" cards (passed all hierarchy filters)
|
|
398
534
|
* in the new content. Returns -1 if no content was loaded.
|
|
399
535
|
*/
|
|
400
|
-
private async getWeightedContent(options?: {
|
|
536
|
+
private async getWeightedContent(options?: {
|
|
537
|
+
replan?: boolean;
|
|
538
|
+
additive?: boolean;
|
|
539
|
+
limit?: number;
|
|
540
|
+
}): Promise<number> {
|
|
401
541
|
const replan = options?.replan ?? false;
|
|
402
542
|
const additive = options?.additive ?? false;
|
|
403
|
-
const limit =
|
|
543
|
+
const limit = options?.limit ?? this._defaultBatchLimit;
|
|
404
544
|
|
|
405
545
|
// Collect batches from each source
|
|
406
546
|
const batches: SourceBatch[] = [];
|
|
@@ -645,19 +785,69 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
645
785
|
await this._replanPromise;
|
|
646
786
|
}
|
|
647
787
|
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
788
|
+
// --- Auto-replan triggers ---
|
|
789
|
+
// Two automatic replan triggers maintain queue freshness:
|
|
790
|
+
//
|
|
791
|
+
// 1. Depletion: newQ is running dry → fire a replan so fresh content
|
|
792
|
+
// is ready by the time the last card is consumed.
|
|
793
|
+
// - newQ === 1: background replan — overlaps pipeline latency with
|
|
794
|
+
// the user's interaction on the last card. On the *next*
|
|
795
|
+
// nextCard(), the _replanPromise await at the top ensures the
|
|
796
|
+
// replan has landed before we try to draw.
|
|
797
|
+
// - newQ === 0 && all queues empty: blocking await — nothing else
|
|
798
|
+
// to serve, so we must wait for the pipeline before proceeding.
|
|
799
|
+
// - newQ === 0 && other queues have content: background — the user
|
|
800
|
+
// draws from reviewQ/failedQ while the pipeline runs.
|
|
801
|
+
//
|
|
802
|
+
// 2. Quality: few well-indicated cards remain → background replan so
|
|
803
|
+
// the refreshed queue is ready by the time the buffer is consumed.
|
|
804
|
+
// Suppressed after a burst replan to avoid clobbering burst cards.
|
|
805
|
+
|
|
806
|
+
// 1. Depletion trigger
|
|
807
|
+
// Guarded by _depletionReplanAttempted to avoid infinite loops when
|
|
808
|
+
// the pipeline consistently returns no new content.
|
|
809
|
+
if (
|
|
810
|
+
this.newQ.length <= 1 &&
|
|
811
|
+
this._secondsRemaining > 0 &&
|
|
812
|
+
!this._replanPromise &&
|
|
813
|
+
!this._depletionReplanAttempted
|
|
814
|
+
) {
|
|
815
|
+
this._suppressQualityReplan = false; // burst is (nearly) consumed, clear suppression
|
|
816
|
+
this._depletionReplanAttempted = true;
|
|
817
|
+
|
|
818
|
+
const otherContent = this.reviewQ.length + this.failedQ.length;
|
|
819
|
+
|
|
820
|
+
if (this.newQ.length === 0 && otherContent === 0) {
|
|
821
|
+
// Truly empty — nothing to serve. Must block until the pipeline delivers.
|
|
822
|
+
this.log(
|
|
823
|
+
`[AutoReplan:depletion] All queues empty with ${this._secondsRemaining}s remaining. ` +
|
|
824
|
+
`Awaiting replan.`
|
|
825
|
+
);
|
|
826
|
+
await this.requestReplan();
|
|
827
|
+
} else {
|
|
828
|
+
// Either 1 card remains (look-ahead) or other queues can cover.
|
|
829
|
+
// Fire in background — pipeline runs while the user works.
|
|
830
|
+
this.log(
|
|
831
|
+
`[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) ` +
|
|
832
|
+
`(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
|
|
833
|
+
`Triggering background replan.`
|
|
834
|
+
);
|
|
835
|
+
void this.requestReplan();
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// 2. Quality trigger: few well-indicated cards remain. The buffer of
|
|
840
|
+
// remaining good cards covers replan latency.
|
|
841
|
+
// Suppressed after a burst replan to avoid clobbering burst cards.
|
|
653
842
|
const REPLAN_BUFFER = 3;
|
|
654
843
|
if (
|
|
844
|
+
!this._suppressQualityReplan &&
|
|
655
845
|
this._wellIndicatedRemaining <= REPLAN_BUFFER &&
|
|
656
846
|
this.newQ.length > 0 &&
|
|
657
847
|
!this._replanPromise
|
|
658
848
|
) {
|
|
659
849
|
this.log(
|
|
660
|
-
`[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
|
|
850
|
+
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
|
|
661
851
|
`(newQ: ${this.newQ.length}). Triggering background replan.`
|
|
662
852
|
);
|
|
663
853
|
void this.requestReplan();
|
|
@@ -107,9 +107,11 @@ export class ResponseProcessor {
|
|
|
107
107
|
try {
|
|
108
108
|
const history = await cardHistory;
|
|
109
109
|
|
|
110
|
+
let result: ResponseResult;
|
|
111
|
+
|
|
110
112
|
// Handle correct responses
|
|
111
113
|
if (cardRecord.isCorrect) {
|
|
112
|
-
|
|
114
|
+
result = this.processCorrectResponse(
|
|
113
115
|
cardRecord,
|
|
114
116
|
history,
|
|
115
117
|
studySessionItem,
|
|
@@ -120,7 +122,7 @@ export class ResponseProcessor {
|
|
|
120
122
|
);
|
|
121
123
|
} else {
|
|
122
124
|
// Handle incorrect responses
|
|
123
|
-
|
|
125
|
+
result = this.processIncorrectResponse(
|
|
124
126
|
cardRecord,
|
|
125
127
|
history,
|
|
126
128
|
courseRegistrationDoc,
|
|
@@ -132,6 +134,24 @@ export class ResponseProcessor {
|
|
|
132
134
|
sessionViews
|
|
133
135
|
);
|
|
134
136
|
}
|
|
137
|
+
|
|
138
|
+
// Apply deferred advancement: record is logged and ELO updated above,
|
|
139
|
+
// but we suppress navigation so the view retains control of the UI
|
|
140
|
+
// timeline. StudySession will stash the nextCardAction and wait for
|
|
141
|
+
// a `ready-to-advance` event from the view.
|
|
142
|
+
if (cardRecord.deferAdvance && result.shouldLoadNextCard) {
|
|
143
|
+
logger.info(
|
|
144
|
+
'[ResponseProcessor] deferAdvance requested — suppressing navigation, action stashed:',
|
|
145
|
+
{ nextCardAction: result.nextCardAction }
|
|
146
|
+
);
|
|
147
|
+
result = {
|
|
148
|
+
...result,
|
|
149
|
+
shouldLoadNextCard: false,
|
|
150
|
+
deferred: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return result;
|
|
135
155
|
} catch (e: unknown) {
|
|
136
156
|
logger.error('[ResponseProcessor] Failed to load card history', { e, cardId });
|
|
137
157
|
throw e;
|