@vue-skuilder/db 0.1.32-a → 0.1.32-c
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 +16 -12
- package/dist/core/index.d.ts +16 -12
- package/dist/core/index.js +2279 -227
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2256 -200
- package/dist/core/index.mjs.map +1 -1
- package/dist/{contentSource-Bdwkvqa8.d.ts → dataLayerProvider-BAn-LRh5.d.ts} +626 -83
- package/dist/{contentSource-DF1nUbPQ.d.cts → dataLayerProvider-BJqBlMIl.d.cts} +626 -83
- package/dist/impl/couch/index.d.cts +18 -3
- package/dist/impl/couch/index.d.ts +18 -3
- package/dist/impl/couch/index.js +2323 -224
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2311 -208
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +5 -4
- package/dist/impl/static/index.d.ts +5 -4
- package/dist/impl/static/index.js +2283 -231
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +2268 -212
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-BWvO-_rJ.d.ts → index-X6wHrURm.d.ts} +1 -1
- package/dist/{index-Ba7hYbHj.d.cts → index-m8MMGxxR.d.cts} +1 -1
- package/dist/index.d.cts +9 -381
- package/dist/index.d.ts +9 -381
- package/dist/index.js +9626 -8815
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9559 -8748
- package/dist/index.mjs.map +1 -1
- package/dist/{types-CJrLM1Ew.d.ts → types-DZ5dUqbL.d.ts} +1 -1
- package/dist/{types-W8n-B6HG.d.cts → types-ZL8tOPQZ.d.cts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-C7r0T4OV.d.cts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-C7r0T4OV.d.ts} +1 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/navigators-architecture.md +2 -2
- package/package.json +2 -2
- package/src/core/interfaces/contentSource.ts +2 -1
- package/src/core/navigators/Pipeline.ts +51 -25
- package/src/core/navigators/PipelineDebugger.ts +49 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +92 -5
- package/src/core/navigators/filters/relativePriority.ts +7 -1
- package/src/core/navigators/generators/prescribed.ts +618 -43
- package/src/core/navigators/index.ts +2 -1
- package/src/impl/couch/CourseSyncService.ts +72 -4
- package/src/impl/couch/courseDB.ts +11 -0
- package/src/impl/static/courseDB.ts +13 -0
- package/src/study/SessionController.ts +276 -24
- package/src/study/services/EloService.ts +22 -3
- package/src/study/services/ResponseProcessor.ts +7 -3
- package/dist/dataLayerProvider-BKmVoyJR.d.ts +0 -67
- package/dist/dataLayerProvider-BQdfJuBN.d.cts +0 -67
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { StudyContentSource, UserDBInterface, CourseDBInterface } from '..';
|
|
2
|
+
import type { ReplanHints } from '@db/study/SessionController';
|
|
2
3
|
|
|
3
4
|
// Re-export filter types
|
|
4
5
|
export type { CardFilter, FilterContext, CardFilterFactory } from './filters/types';
|
|
@@ -633,7 +634,7 @@ export abstract class ContentNavigator implements StudyContentSource {
|
|
|
633
634
|
* Set ephemeral hints for the next pipeline run.
|
|
634
635
|
* No-op for non-Pipeline navigators. Pipeline overrides this.
|
|
635
636
|
*/
|
|
636
|
-
setEphemeralHints(_hints:
|
|
637
|
+
setEphemeralHints(_hints: ReplanHints): void {
|
|
637
638
|
// no-op — only Pipeline implements this
|
|
638
639
|
}
|
|
639
640
|
}
|
|
@@ -137,9 +137,26 @@ export class CourseSyncService {
|
|
|
137
137
|
async ensureSynced(courseId: string, forceEnabled?: boolean): Promise<void> {
|
|
138
138
|
const existing = this.entries.get(courseId);
|
|
139
139
|
|
|
140
|
-
// Already synced
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// Already synced — but check if the remote DB has been recreated
|
|
141
|
+
// (e.g., after a dev reseed). The seed script writes a `db-epoch`
|
|
142
|
+
// doc; if the remote epoch differs from our local copy, the local
|
|
143
|
+
// replica is stale and must be destroyed before re-syncing.
|
|
144
|
+
if (existing?.status.state === 'ready' && existing.localDB) {
|
|
145
|
+
const stale = await this.isLocalEpochStale(courseId, existing.localDB);
|
|
146
|
+
if (!stale) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
logger.info(
|
|
150
|
+
`[CourseSyncService] Remote DB epoch changed for course ${courseId} — destroying stale local replica`
|
|
151
|
+
);
|
|
152
|
+
try {
|
|
153
|
+
await existing.localDB.destroy();
|
|
154
|
+
} catch {
|
|
155
|
+
// Ignore cleanup errors
|
|
156
|
+
}
|
|
157
|
+
existing.localDB = null;
|
|
158
|
+
existing.readyPromise = null;
|
|
159
|
+
// Fall through to start a fresh sync
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
// Already disabled
|
|
@@ -224,7 +241,23 @@ export class CourseSyncService {
|
|
|
224
241
|
// Step 2: Create local PouchDB and replicate
|
|
225
242
|
entry.status = { state: 'syncing' };
|
|
226
243
|
const localDBName = this.localDBName(courseId);
|
|
227
|
-
|
|
244
|
+
let localDB = new pouch(localDBName);
|
|
245
|
+
|
|
246
|
+
// Check for stale local replica before replicating. If the remote DB
|
|
247
|
+
// was wiped and recreated (e.g., `yarn db:seed`), the local PouchDB
|
|
248
|
+
// has documents at rev 1-oldHash while the remote has 1-newHash for
|
|
249
|
+
// the same _ids. PouchDB replication treats this as a conflict rather
|
|
250
|
+
// than an update, and the stale revision can win — causing partial or
|
|
251
|
+
// incorrect data. Destroying the stale local DB avoids this entirely.
|
|
252
|
+
const stale = await this.isLocalEpochStale(courseId, localDB);
|
|
253
|
+
if (stale) {
|
|
254
|
+
logger.info(
|
|
255
|
+
`[CourseSyncService] Stale local DB detected for course ${courseId} — destroying before sync`
|
|
256
|
+
);
|
|
257
|
+
await localDB.destroy();
|
|
258
|
+
localDB = new pouch(localDBName);
|
|
259
|
+
}
|
|
260
|
+
|
|
228
261
|
entry.localDB = localDB;
|
|
229
262
|
|
|
230
263
|
const remoteDB = this.getRemoteDB(courseId);
|
|
@@ -340,6 +373,41 @@ export class CourseSyncService {
|
|
|
340
373
|
}
|
|
341
374
|
}
|
|
342
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Check whether the local replica's `db-epoch` doc matches the remote.
|
|
378
|
+
*
|
|
379
|
+
* The seed script (and optionally upload-cards) writes a `db-epoch`
|
|
380
|
+
* document with a numeric timestamp. If the remote epoch differs from
|
|
381
|
+
* the local copy, the remote DB was recreated (e.g., `yarn db:seed`)
|
|
382
|
+
* and the local PouchDB is stale.
|
|
383
|
+
*
|
|
384
|
+
* Returns `true` if stale (epoch mismatch or remote has epoch but local
|
|
385
|
+
* doesn't). Returns `false` (not stale) if epochs match, or if the
|
|
386
|
+
* remote doesn't have an epoch doc at all (backwards compat).
|
|
387
|
+
*/
|
|
388
|
+
private async isLocalEpochStale(
|
|
389
|
+
courseId: string,
|
|
390
|
+
localDB: PouchDB.Database
|
|
391
|
+
): Promise<boolean> {
|
|
392
|
+
try {
|
|
393
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
394
|
+
const remoteEpoch = await remoteDB.get<{ epoch: number }>('db-epoch');
|
|
395
|
+
|
|
396
|
+
let localEpoch: { epoch: number } | null = null;
|
|
397
|
+
try {
|
|
398
|
+
localEpoch = await localDB.get<{ epoch: number }>('db-epoch');
|
|
399
|
+
} catch {
|
|
400
|
+
// Local doesn't have the epoch doc — stale
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return remoteEpoch.epoch !== localEpoch.epoch;
|
|
405
|
+
} catch {
|
|
406
|
+
// Remote doesn't have db-epoch — no epoch tracking, not stale
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
343
411
|
/**
|
|
344
412
|
* Get a remote PouchDB handle for a course.
|
|
345
413
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CourseDBInterface, CourseInfo, CoursesDBInterface, UserDBInterface } from '@db/core';
|
|
2
|
+
import type { ReplanHints } from '@db/study/SessionController';
|
|
2
3
|
import {
|
|
3
4
|
CourseConfig,
|
|
4
5
|
CourseElo,
|
|
@@ -650,11 +651,21 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
650
651
|
* @param limit - Maximum number of cards to return
|
|
651
652
|
* @returns Cards sorted by score descending
|
|
652
653
|
*/
|
|
654
|
+
private _pendingHints: ReplanHints | null = null;
|
|
655
|
+
|
|
656
|
+
public setEphemeralHints(hints: ReplanHints): void {
|
|
657
|
+
this._pendingHints = hints;
|
|
658
|
+
}
|
|
659
|
+
|
|
653
660
|
public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
654
661
|
const u = await this._getCurrentUser();
|
|
655
662
|
|
|
656
663
|
try {
|
|
657
664
|
const navigator = await this.createNavigator(u);
|
|
665
|
+
if (this._pendingHints) {
|
|
666
|
+
navigator.setEphemeralHints(this._pendingHints);
|
|
667
|
+
this._pendingHints = null;
|
|
668
|
+
}
|
|
658
669
|
return navigator.getWeightedCards(limit);
|
|
659
670
|
} catch (e) {
|
|
660
671
|
logger.error(`[courseDB] Error getting weighted cards: ${e}`);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CourseInfo,
|
|
7
7
|
StudySessionItem,
|
|
8
8
|
} from '../../core/interfaces';
|
|
9
|
+
import type { ReplanHints } from '@db/study/SessionController';
|
|
9
10
|
import { StaticDataUnpacker } from './StaticDataUnpacker';
|
|
10
11
|
import { StaticCourseManifest } from '../../util/packer/types';
|
|
11
12
|
import { CourseConfig, CourseElo, DataShape, Status } from '@vue-skuilder/common';
|
|
@@ -442,9 +443,21 @@ export class StaticCourseDB implements CourseDBInterface {
|
|
|
442
443
|
}
|
|
443
444
|
|
|
444
445
|
// Study Content Source implementation
|
|
446
|
+
|
|
447
|
+
private _pendingHints: ReplanHints | null = null;
|
|
448
|
+
|
|
449
|
+
setEphemeralHints(hints: ReplanHints): void {
|
|
450
|
+
this._pendingHints = hints;
|
|
451
|
+
}
|
|
452
|
+
|
|
445
453
|
async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
446
454
|
try {
|
|
447
455
|
const navigator = await this.createNavigator(this.userDB);
|
|
456
|
+
// Forward any pending hints to the Pipeline
|
|
457
|
+
if (this._pendingHints) {
|
|
458
|
+
navigator.setEphemeralHints(this._pendingHints);
|
|
459
|
+
this._pendingHints = null;
|
|
460
|
+
}
|
|
448
461
|
return navigator.getWeightedCards(limit);
|
|
449
462
|
} catch (e) {
|
|
450
463
|
logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
|
|
@@ -20,6 +20,69 @@ 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
|
+
* Typed ephemeral pipeline hints for a single run.
|
|
25
|
+
* All fields are optional. Tag/card patterns support `*` wildcards.
|
|
26
|
+
*
|
|
27
|
+
* Previously defined in Pipeline.ts; moved here so it's co-exported
|
|
28
|
+
* with ReplanOptions from the public `@vue-skuilder/db` surface.
|
|
29
|
+
*/
|
|
30
|
+
export interface ReplanHints {
|
|
31
|
+
/** Multiply scores for cards matching these tag patterns. */
|
|
32
|
+
boostTags?: Record<string, number>;
|
|
33
|
+
/** Multiply scores for these specific card IDs (glob patterns). */
|
|
34
|
+
boostCards?: Record<string, number>;
|
|
35
|
+
/** Cards matching these tag patterns MUST appear in results. */
|
|
36
|
+
requireTags?: string[];
|
|
37
|
+
/** These specific card IDs MUST appear in results. */
|
|
38
|
+
requireCards?: string[];
|
|
39
|
+
/** Remove cards matching these tag patterns from results. */
|
|
40
|
+
excludeTags?: string[];
|
|
41
|
+
/** Remove these specific card IDs from results. */
|
|
42
|
+
excludeCards?: string[];
|
|
43
|
+
/**
|
|
44
|
+
* Debugging label threaded from the replan requester.
|
|
45
|
+
* Prefixed with `_` to signal it's metadata, not a scoring hint.
|
|
46
|
+
*/
|
|
47
|
+
_label?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Options for requesting a mid-session replan.
|
|
52
|
+
*
|
|
53
|
+
* All fields are optional — callers can pass just the fields they need.
|
|
54
|
+
* When omitted, defaults match the existing behaviour (full 20-card
|
|
55
|
+
* replace with no hints).
|
|
56
|
+
*/
|
|
57
|
+
export interface ReplanOptions {
|
|
58
|
+
/** Scoring hints forwarded to the pipeline (boost/exclude/require). */
|
|
59
|
+
hints?: ReplanHints;
|
|
60
|
+
/**
|
|
61
|
+
* Maximum number of new cards to return from the pipeline.
|
|
62
|
+
* Default: 20 (the standard session batch size).
|
|
63
|
+
*/
|
|
64
|
+
limit?: number;
|
|
65
|
+
/**
|
|
66
|
+
* How to integrate the new cards into the existing newQ.
|
|
67
|
+
* - `'replace'` (default): atomically swap the entire newQ.
|
|
68
|
+
* - `'merge'`: insert new cards at the front, keeping existing cards.
|
|
69
|
+
*/
|
|
70
|
+
mode?: 'replace' | 'merge';
|
|
71
|
+
/**
|
|
72
|
+
* Guarantee that at least this many cards will be served after the
|
|
73
|
+
* replan, even if the session timer has expired. Prevents intro cards
|
|
74
|
+
* from surfacing at the end of a session with zero follow-up exercise.
|
|
75
|
+
* Decremented on each card draw while active.
|
|
76
|
+
*/
|
|
77
|
+
minFollowUpCards?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Human-readable label for debugging / provenance.
|
|
80
|
+
* Appears in console logs and in card provenance entries created
|
|
81
|
+
* by ephemeral hint application.
|
|
82
|
+
*/
|
|
83
|
+
label?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
23
86
|
export interface StudySessionRecord {
|
|
24
87
|
card: {
|
|
25
88
|
course_id: string;
|
|
@@ -75,6 +138,13 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
75
138
|
private dataLayer: DataLayerProvider;
|
|
76
139
|
private courseNameCache: Map<string, string> = new Map();
|
|
77
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Default pipeline batch size for new-card planning.
|
|
143
|
+
* Set via constructor options; falls back to 20 when not specified.
|
|
144
|
+
* Individual replans can override via `ReplanOptions.limit`.
|
|
145
|
+
*/
|
|
146
|
+
private _defaultBatchLimit: number = 20;
|
|
147
|
+
|
|
78
148
|
private sources: StudyContentSource[];
|
|
79
149
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
80
150
|
private _sessionRecord: StudySessionRecord[] = [];
|
|
@@ -104,12 +174,38 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
104
174
|
*/
|
|
105
175
|
private _wellIndicatedRemaining: number = 0;
|
|
106
176
|
|
|
177
|
+
/**
|
|
178
|
+
* When true, suppresses the quality-based auto-replan trigger in
|
|
179
|
+
* nextCard(). Set after a burst replan (small limit) to prevent the
|
|
180
|
+
* auto-replan from clobbering the burst cards before they're consumed.
|
|
181
|
+
* Cleared when the depletion-triggered replan fires (newQ exhausted).
|
|
182
|
+
*/
|
|
183
|
+
private _suppressQualityReplan: boolean = false;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Guards against infinite depletion-triggered replans. Set to true
|
|
187
|
+
* when a depletion replan fires; cleared when a replan produces
|
|
188
|
+
* content (newQ.length > 0 after replan) or when an explicit
|
|
189
|
+
* (non-auto) replan is requested.
|
|
190
|
+
*/
|
|
191
|
+
private _depletionReplanAttempted: boolean = false;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* When > 0, the session timer cannot end the session. Decremented on
|
|
195
|
+
* each nextCard() draw. Set by replans that include `minFollowUpCards`.
|
|
196
|
+
*/
|
|
197
|
+
private _minCardsGuarantee: number = 0;
|
|
198
|
+
|
|
107
199
|
private startTime: Date;
|
|
108
200
|
private endTime: Date;
|
|
109
201
|
private _secondsRemaining: number;
|
|
110
202
|
public get secondsRemaining(): number {
|
|
111
203
|
return this._secondsRemaining;
|
|
112
204
|
}
|
|
205
|
+
/** True when a card guarantee is active, preventing timer-based session end. */
|
|
206
|
+
public get hasCardGuarantee(): boolean {
|
|
207
|
+
return this._minCardsGuarantee > 0;
|
|
208
|
+
}
|
|
113
209
|
public get report(): string {
|
|
114
210
|
const reviewCount = this.reviewQ.dequeueCount;
|
|
115
211
|
const newCount = this.newQ.dequeueCount;
|
|
@@ -129,13 +225,18 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
129
225
|
* @param dataLayer - Data layer provider
|
|
130
226
|
* @param getViewComponent - Function to resolve view components
|
|
131
227
|
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
228
|
+
* @param options - Optional session-level configuration
|
|
229
|
+
* @param options.defaultBatchLimit - Default pipeline batch size (default: 20).
|
|
230
|
+
* Smaller values for newer users cause more frequent replans, keeping plans
|
|
231
|
+
* aligned with rapidly-changing user state.
|
|
132
232
|
*/
|
|
133
233
|
constructor(
|
|
134
234
|
sources: StudyContentSource[],
|
|
135
235
|
time: number,
|
|
136
236
|
dataLayer: DataLayerProvider,
|
|
137
237
|
getViewComponent: (viewId: string) => TView,
|
|
138
|
-
mixer?: SourceMixer
|
|
238
|
+
mixer?: SourceMixer,
|
|
239
|
+
options?: { defaultBatchLimit?: number }
|
|
139
240
|
) {
|
|
140
241
|
super();
|
|
141
242
|
|
|
@@ -159,9 +260,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
159
260
|
this._secondsRemaining = time;
|
|
160
261
|
this.endTime = new Date(this.startTime.valueOf() + 1000 * this._secondsRemaining);
|
|
161
262
|
|
|
263
|
+
if (options?.defaultBatchLimit !== undefined) {
|
|
264
|
+
this._defaultBatchLimit = options.defaultBatchLimit;
|
|
265
|
+
}
|
|
266
|
+
|
|
162
267
|
this.log(`Session constructed:
|
|
163
268
|
startTime: ${this.startTime}
|
|
164
|
-
endTime: ${this.endTime}
|
|
269
|
+
endTime: ${this.endTime}
|
|
270
|
+
defaultBatchLimit: ${this._defaultBatchLimit}`);
|
|
165
271
|
}
|
|
166
272
|
|
|
167
273
|
private tick() {
|
|
@@ -254,22 +360,58 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
254
360
|
* Typical trigger: application-level code (e.g. after a GPC intro completion)
|
|
255
361
|
* calls this to ensure newly-unlocked content appears in the session.
|
|
256
362
|
*/
|
|
257
|
-
public async requestReplan(
|
|
363
|
+
public async requestReplan(options?: ReplanOptions | ReplanHints): Promise<void> {
|
|
364
|
+
// Normalise: bare hints object (legacy callers) → ReplanOptions wrapper
|
|
365
|
+
const opts = this.normalizeReplanOptions(options);
|
|
366
|
+
|
|
367
|
+
// Explicit (non-auto) replans clear the depletion guard — the caller
|
|
368
|
+
// is providing fresh intent that may change pipeline results.
|
|
369
|
+
if (opts.hints || opts.label || opts.limit) {
|
|
370
|
+
this._depletionReplanAttempted = false;
|
|
371
|
+
}
|
|
372
|
+
|
|
258
373
|
if (this._replanPromise) {
|
|
259
374
|
this.log('Replan already in progress, awaiting existing replan');
|
|
260
375
|
return this._replanPromise;
|
|
261
376
|
}
|
|
262
377
|
|
|
263
|
-
//
|
|
264
|
-
|
|
378
|
+
// Auto-exclude the currently-displayed card so the replan doesn't
|
|
379
|
+
// surface it again (avoids showing the same card twice in a row).
|
|
380
|
+
if (this._currentCard?.item.cardID) {
|
|
381
|
+
const currentId = this._currentCard.item.cardID;
|
|
382
|
+
if (!opts.hints) opts.hints = {};
|
|
383
|
+
const hints = opts.hints;
|
|
384
|
+
const excludeCards = hints.excludeCards ?? [];
|
|
385
|
+
if (!excludeCards.includes(currentId)) {
|
|
386
|
+
excludeCards.push(currentId);
|
|
387
|
+
}
|
|
388
|
+
hints.excludeCards = excludeCards;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Forward hints to all sources (CourseDB stashes them, Pipeline consumes them)
|
|
392
|
+
if (opts.hints) {
|
|
393
|
+
// Thread label into hints so Pipeline can attach it to provenance
|
|
394
|
+
const hintsWithLabel = opts.label
|
|
395
|
+
? { ...opts.hints, _label: opts.label }
|
|
396
|
+
: opts.hints;
|
|
265
397
|
for (const source of this.sources) {
|
|
266
|
-
|
|
267
|
-
source.setEphemeralHints?.(hints);
|
|
398
|
+
source.setEphemeralHints?.(hintsWithLabel);
|
|
268
399
|
}
|
|
269
400
|
}
|
|
270
401
|
|
|
271
|
-
|
|
272
|
-
this.
|
|
402
|
+
const labelTag = opts.label ? ` [${opts.label}]` : '';
|
|
403
|
+
this.log(
|
|
404
|
+
`Mid-session replan requested${labelTag}` +
|
|
405
|
+
` (limit: ${opts.limit ?? 'default'}, mode: ${opts.mode ?? 'replace'}` +
|
|
406
|
+
`${opts.hints ? ', with hints' : ''})`
|
|
407
|
+
);
|
|
408
|
+
// Update card guarantee if requested
|
|
409
|
+
if (opts.minFollowUpCards !== undefined && opts.minFollowUpCards > 0) {
|
|
410
|
+
this._minCardsGuarantee = Math.max(this._minCardsGuarantee, opts.minFollowUpCards);
|
|
411
|
+
this.log(`[Replan] Card guarantee set to ${this._minCardsGuarantee}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
this._replanPromise = this._executeReplan(opts);
|
|
273
415
|
|
|
274
416
|
try {
|
|
275
417
|
await this._replanPromise;
|
|
@@ -278,6 +420,28 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
278
420
|
}
|
|
279
421
|
}
|
|
280
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Normalise the requestReplan argument. Accepts either a ReplanOptions
|
|
425
|
+
* object (new API) or a plain Record<string, unknown> (legacy callers
|
|
426
|
+
* that passed hints directly). Distinguishes the two by checking for
|
|
427
|
+
* the presence of ReplanOptions-specific keys.
|
|
428
|
+
*/
|
|
429
|
+
private normalizeReplanOptions(
|
|
430
|
+
input?: ReplanOptions | ReplanHints
|
|
431
|
+
): ReplanOptions {
|
|
432
|
+
if (!input) return {};
|
|
433
|
+
|
|
434
|
+
// If the input has any ReplanOptions-specific key, treat it as ReplanOptions
|
|
435
|
+
const replanKeys = ['hints', 'limit', 'mode', 'label', 'minFollowUpCards'];
|
|
436
|
+
const inputKeys = Object.keys(input);
|
|
437
|
+
if (inputKeys.some((k) => replanKeys.includes(k))) {
|
|
438
|
+
return input as ReplanOptions;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Otherwise treat as legacy bare-hints object
|
|
442
|
+
return { hints: input as ReplanHints };
|
|
443
|
+
}
|
|
444
|
+
|
|
281
445
|
/** Minimum well-indicated cards before an additive retry is attempted */
|
|
282
446
|
private static readonly MIN_WELL_INDICATED = 5;
|
|
283
447
|
|
|
@@ -298,18 +462,43 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
298
462
|
* pass all hierarchy filters, one additive retry is attempted — merging
|
|
299
463
|
* any new high-quality candidates into the front of the queue.
|
|
300
464
|
*/
|
|
301
|
-
private async _executeReplan(): Promise<void> {
|
|
302
|
-
const
|
|
465
|
+
private async _executeReplan(opts: ReplanOptions = {}): Promise<void> {
|
|
466
|
+
const limit = opts.limit;
|
|
467
|
+
const mode = opts.mode ?? 'replace';
|
|
468
|
+
|
|
469
|
+
const wellIndicated = await this.getWeightedContent({
|
|
470
|
+
replan: true,
|
|
471
|
+
additive: mode === 'merge',
|
|
472
|
+
limit,
|
|
473
|
+
});
|
|
303
474
|
this._wellIndicatedRemaining = wellIndicated;
|
|
304
475
|
|
|
476
|
+
// Burst replan: suppress quality-based auto-replan so the background
|
|
477
|
+
// replan doesn't clobber the small hinted queue before it's consumed.
|
|
478
|
+
// The depletion trigger (newQ empty) takes over instead.
|
|
479
|
+
if (limit !== undefined && limit < this._defaultBatchLimit) {
|
|
480
|
+
this._suppressQualityReplan = true;
|
|
481
|
+
this.log(`[Replan] Burst mode (limit=${limit}): suppressing quality-based auto-replan`);
|
|
482
|
+
} else {
|
|
483
|
+
// Normal or auto-replan — clear the burst suppression flag
|
|
484
|
+
this._suppressQualityReplan = false;
|
|
485
|
+
}
|
|
486
|
+
|
|
305
487
|
if (wellIndicated >= 0 && wellIndicated < SessionController.MIN_WELL_INDICATED) {
|
|
306
488
|
this.log(
|
|
307
489
|
`[Replan] Only ${wellIndicated}/${SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
|
|
308
490
|
);
|
|
309
491
|
}
|
|
310
492
|
|
|
493
|
+
// If the replan produced content, clear the depletion guard so future
|
|
494
|
+
// depletions can trigger fresh replans.
|
|
495
|
+
if (this.newQ.length > 0) {
|
|
496
|
+
this._depletionReplanAttempted = false;
|
|
497
|
+
}
|
|
498
|
+
|
|
311
499
|
await this.hydrationService.ensureHydratedCards();
|
|
312
|
-
|
|
500
|
+
const labelTag = opts.label ? ` [${opts.label}]` : '';
|
|
501
|
+
this.log(`Replan complete${labelTag}: newQ now has ${this.newQ.length} cards (mode=${mode})`);
|
|
313
502
|
|
|
314
503
|
// Snapshot queue state for debugging
|
|
315
504
|
snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
@@ -381,6 +570,9 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
381
570
|
},
|
|
382
571
|
replan: {
|
|
383
572
|
inProgress: this._replanPromise !== null,
|
|
573
|
+
suppressQualityReplan: this._suppressQualityReplan,
|
|
574
|
+
defaultBatchLimit: this._defaultBatchLimit,
|
|
575
|
+
minCardsGuarantee: this._minCardsGuarantee,
|
|
384
576
|
},
|
|
385
577
|
};
|
|
386
578
|
}
|
|
@@ -405,10 +597,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
405
597
|
* @returns Number of "well-indicated" cards (passed all hierarchy filters)
|
|
406
598
|
* in the new content. Returns -1 if no content was loaded.
|
|
407
599
|
*/
|
|
408
|
-
private async getWeightedContent(options?: {
|
|
600
|
+
private async getWeightedContent(options?: {
|
|
601
|
+
replan?: boolean;
|
|
602
|
+
additive?: boolean;
|
|
603
|
+
limit?: number;
|
|
604
|
+
}): Promise<number> {
|
|
409
605
|
const replan = options?.replan ?? false;
|
|
410
606
|
const additive = options?.additive ?? false;
|
|
411
|
-
const limit =
|
|
607
|
+
const limit = options?.limit ?? this._defaultBatchLimit;
|
|
412
608
|
|
|
413
609
|
// Collect batches from each source
|
|
414
610
|
const batches: SourceBatch[] = [];
|
|
@@ -577,13 +773,13 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
577
773
|
return null;
|
|
578
774
|
}
|
|
579
775
|
|
|
580
|
-
if (this._secondsRemaining < 2 && this.failedQ.length === 0) {
|
|
776
|
+
if (this._secondsRemaining < 2 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
581
777
|
// session is over!
|
|
582
778
|
return null;
|
|
583
779
|
}
|
|
584
780
|
|
|
585
|
-
// If timer expired, only return failed cards
|
|
586
|
-
if (this._secondsRemaining <= 0) {
|
|
781
|
+
// If timer expired, only return failed cards (unless card guarantee active)
|
|
782
|
+
if (this._secondsRemaining <= 0 && this._minCardsGuarantee <= 0) {
|
|
587
783
|
if (this.failedQ.length > 0) {
|
|
588
784
|
return this.failedQ.peek(0);
|
|
589
785
|
} else {
|
|
@@ -645,6 +841,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
645
841
|
// dismiss (or sort to failedQ) the current card
|
|
646
842
|
this.dismissCurrentCard(action);
|
|
647
843
|
|
|
844
|
+
// Decrement card guarantee counter
|
|
845
|
+
if (this._minCardsGuarantee > 0) {
|
|
846
|
+
this._minCardsGuarantee--;
|
|
847
|
+
this.log(`[CardGuarantee] ${this._minCardsGuarantee} guaranteed cards remaining`);
|
|
848
|
+
}
|
|
849
|
+
|
|
648
850
|
// If a replan is in flight, wait for it to complete before drawing.
|
|
649
851
|
// This ensures the user sees cards scored against their latest state
|
|
650
852
|
// (e.g. after a GPC intro unlocked new content).
|
|
@@ -653,25 +855,75 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
653
855
|
await this._replanPromise;
|
|
654
856
|
}
|
|
655
857
|
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
858
|
+
// --- Auto-replan triggers ---
|
|
859
|
+
// Two automatic replan triggers maintain queue freshness:
|
|
860
|
+
//
|
|
861
|
+
// 1. Depletion: newQ is running dry → fire a replan so fresh content
|
|
862
|
+
// is ready by the time the last card is consumed.
|
|
863
|
+
// - newQ === 1: background replan — overlaps pipeline latency with
|
|
864
|
+
// the user's interaction on the last card. On the *next*
|
|
865
|
+
// nextCard(), the _replanPromise await at the top ensures the
|
|
866
|
+
// replan has landed before we try to draw.
|
|
867
|
+
// - newQ === 0 && all queues empty: blocking await — nothing else
|
|
868
|
+
// to serve, so we must wait for the pipeline before proceeding.
|
|
869
|
+
// - newQ === 0 && other queues have content: background — the user
|
|
870
|
+
// draws from reviewQ/failedQ while the pipeline runs.
|
|
871
|
+
//
|
|
872
|
+
// 2. Quality: few well-indicated cards remain → background replan so
|
|
873
|
+
// the refreshed queue is ready by the time the buffer is consumed.
|
|
874
|
+
// Suppressed after a burst replan to avoid clobbering burst cards.
|
|
875
|
+
|
|
876
|
+
// 1. Depletion trigger
|
|
877
|
+
// Guarded by _depletionReplanAttempted to avoid infinite loops when
|
|
878
|
+
// the pipeline consistently returns no new content.
|
|
879
|
+
if (
|
|
880
|
+
this.newQ.length <= 1 &&
|
|
881
|
+
this._secondsRemaining > 0 &&
|
|
882
|
+
!this._replanPromise &&
|
|
883
|
+
!this._depletionReplanAttempted
|
|
884
|
+
) {
|
|
885
|
+
this._suppressQualityReplan = false; // burst is (nearly) consumed, clear suppression
|
|
886
|
+
this._depletionReplanAttempted = true;
|
|
887
|
+
|
|
888
|
+
const otherContent = this.reviewQ.length + this.failedQ.length;
|
|
889
|
+
|
|
890
|
+
if (this.newQ.length === 0 && otherContent === 0) {
|
|
891
|
+
// Truly empty — nothing to serve. Must block until the pipeline delivers.
|
|
892
|
+
this.log(
|
|
893
|
+
`[AutoReplan:depletion] All queues empty with ${this._secondsRemaining}s remaining. ` +
|
|
894
|
+
`Awaiting replan.`
|
|
895
|
+
);
|
|
896
|
+
await this.requestReplan();
|
|
897
|
+
} else {
|
|
898
|
+
// Either 1 card remains (look-ahead) or other queues can cover.
|
|
899
|
+
// Fire in background — pipeline runs while the user works.
|
|
900
|
+
this.log(
|
|
901
|
+
`[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) ` +
|
|
902
|
+
`(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
|
|
903
|
+
`Triggering background replan.`
|
|
904
|
+
);
|
|
905
|
+
void this.requestReplan();
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// 2. Quality trigger: few well-indicated cards remain. The buffer of
|
|
910
|
+
// remaining good cards covers replan latency.
|
|
911
|
+
// Suppressed after a burst replan to avoid clobbering burst cards.
|
|
661
912
|
const REPLAN_BUFFER = 3;
|
|
662
913
|
if (
|
|
914
|
+
!this._suppressQualityReplan &&
|
|
663
915
|
this._wellIndicatedRemaining <= REPLAN_BUFFER &&
|
|
664
916
|
this.newQ.length > 0 &&
|
|
665
917
|
!this._replanPromise
|
|
666
918
|
) {
|
|
667
919
|
this.log(
|
|
668
|
-
`[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
|
|
920
|
+
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
|
|
669
921
|
`(newQ: ${this.newQ.length}). Triggering background replan.`
|
|
670
922
|
);
|
|
671
923
|
void this.requestReplan();
|
|
672
924
|
}
|
|
673
925
|
|
|
674
|
-
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
926
|
+
if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
675
927
|
this._currentCard = null;
|
|
676
928
|
endSessionTracking();
|
|
677
929
|
return null;
|
|
@@ -111,10 +111,29 @@ export class EloService {
|
|
|
111
111
|
const userElo = toCourseElo(
|
|
112
112
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo
|
|
113
113
|
);
|
|
114
|
-
|
|
114
|
+
|
|
115
|
+
const [cardEloResults, cardTagsMap] = await Promise.all([
|
|
116
|
+
courseDB.getCardEloData([currentCard.card.card_id]),
|
|
117
|
+
courseDB.getAppliedTagsBatch([card_id]),
|
|
118
|
+
]);
|
|
119
|
+
const cardElo = cardEloResults[0];
|
|
120
|
+
|
|
121
|
+
// Enrich TaggedPerformance with card-level tags not explicitly graded by
|
|
122
|
+
// the question's evaluate(). Category tags (concept:*, ui:*, etc.) are not
|
|
123
|
+
// emitted by individual question types; applying the global score as a proxy
|
|
124
|
+
// keeps hierarchy filter ELO thresholds functional without overriding any
|
|
125
|
+
// fine-grained per-GPC scores the question already provided.
|
|
126
|
+
const cardTags = cardTagsMap.get(card_id) ?? [];
|
|
127
|
+
const enriched: TaggedPerformance = { ...taggedPerformance };
|
|
128
|
+
const globalScore = taggedPerformance._global;
|
|
129
|
+
for (const tag of cardTags) {
|
|
130
|
+
if (!(tag in enriched)) {
|
|
131
|
+
enriched[tag] = globalScore;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
115
134
|
|
|
116
135
|
if (cardElo && userElo) {
|
|
117
|
-
const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo,
|
|
136
|
+
const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo, enriched);
|
|
118
137
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo = eloUpdate.userElo;
|
|
119
138
|
|
|
120
139
|
const results = await Promise.allSettled([
|
|
@@ -131,7 +150,7 @@ export class EloService {
|
|
|
131
150
|
const card = (results[1] as PromiseFulfilledResult<any>).value;
|
|
132
151
|
|
|
133
152
|
if (user.ok && card && card.ok) {
|
|
134
|
-
const tagCount = Object.keys(
|
|
153
|
+
const tagCount = Object.keys(enriched).length - 1; // exclude _global
|
|
135
154
|
logger.info(
|
|
136
155
|
`[EloService] Updated ELOS (per-tag, ${tagCount} tags):
|
|
137
156
|
\tUser: ${JSON.stringify(eloUpdate.userElo)})
|
|
@@ -181,6 +181,13 @@ export class ResponseProcessor {
|
|
|
181
181
|
// Update ELO ratings
|
|
182
182
|
if (taggedPerformance) {
|
|
183
183
|
// Per-tag ELO update
|
|
184
|
+
const tagKeys = Object.keys(taggedPerformance).filter((k) => k !== '_global');
|
|
185
|
+
const nullTags = tagKeys.filter((k) => taggedPerformance[k] === null);
|
|
186
|
+
const scoredTags = tagKeys.filter((k) => taggedPerformance[k] !== null);
|
|
187
|
+
logger.info(
|
|
188
|
+
`[ResponseProcessor] per-tag ELO update for ${cardId}: scored=[${scoredTags.join(', ')}] count-only=[${nullTags.join(', ')}]`
|
|
189
|
+
);
|
|
190
|
+
|
|
184
191
|
void this.eloService.updateUserAndCardEloPerTag(
|
|
185
192
|
taggedPerformance,
|
|
186
193
|
courseId,
|
|
@@ -188,9 +195,6 @@ export class ResponseProcessor {
|
|
|
188
195
|
courseRegistrationDoc,
|
|
189
196
|
currentCard
|
|
190
197
|
);
|
|
191
|
-
logger.info(
|
|
192
|
-
`[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
|
|
193
|
-
);
|
|
194
198
|
} else {
|
|
195
199
|
// Standard single-score ELO update (backward compatible)
|
|
196
200
|
const userScore = 0.5 + globalScore / 2;
|