@vue-skuilder/db 0.2.2 → 0.2.4
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-Ht3N2f-y.d.ts → contentSource-Cplhv3bJ.d.ts} +1 -1
- package/dist/{contentSource-BMlMwSiG.d.cts → contentSource-kI9_jwTu.d.cts} +1 -1
- package/dist/core/index.d.cts +5 -5
- package/dist/core/index.d.ts +5 -5
- package/dist/core/index.js +2 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2 -1
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BEqB8VBR.d.cts → dataLayerProvider-CiA2Rr0v.d.cts} +1 -1
- package/dist/{dataLayerProvider-DObSXjnf.d.ts → dataLayerProvider-DrBqOUa3.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +3 -3
- package/dist/impl/couch/index.d.ts +3 -3
- package/dist/impl/couch/index.js +2 -1
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2 -1
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +4 -4
- package/dist/impl/static/index.d.ts +4 -4
- package/dist/impl/static/index.js +2 -1
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +2 -1
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-BWvO-_rJ.d.ts → index-BLLT5BYE.d.ts} +1 -1
- package/dist/{index-Ba7hYbHj.d.cts → index-k9NFHpS1.d.cts} +1 -1
- package/dist/index.d.cts +209 -10
- package/dist/index.d.ts +209 -10
- package/dist/index.js +361 -17
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +361 -17
- package/dist/index.mjs.map +1 -1
- package/dist/{types-W8n-B6HG.d.cts → types-BFUa1pa3.d.cts} +1 -1
- package/dist/{types-CJrLM1Ew.d.ts → types-CHgpWQAY.d.ts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-4tlwHnXo.d.cts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-4tlwHnXo.d.ts} +1 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/package.json +3 -3
- package/src/core/navigators/Pipeline.ts +1 -1
- package/src/study/SessionController.ts +347 -22
- package/src/study/SessionDebugger.ts +10 -0
- package/src/study/SessionOverlay.ts +276 -0
|
@@ -157,4 +157,4 @@ interface QuestionRecord extends CardRecord, Evaluation {
|
|
|
157
157
|
priorAttemps: number;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type
|
|
160
|
+
export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type QuestionRecord as d, type CardData as e, type CourseListData as f, type DisplayableData as g, type DataShapeData as h, type QuestionData as i, log as l };
|
|
@@ -157,4 +157,4 @@ interface QuestionRecord extends CardRecord, Evaluation {
|
|
|
157
157
|
priorAttemps: number;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type
|
|
160
|
+
export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type QuestionRecord as d, type CardData as e, type CourseListData as f, type DisplayableData as g, type DataShapeData as h, type QuestionData as i, log as l };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-
|
|
2
|
-
export { C as CouchDBToStaticPacker } from '../../index-
|
|
1
|
+
export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-BFUa1pa3.cjs';
|
|
2
|
+
export { C as CouchDBToStaticPacker } from '../../index-k9NFHpS1.cjs';
|
|
3
3
|
import '@vue-skuilder/common';
|
|
4
|
-
import '../../types-legacy-
|
|
4
|
+
import '../../types-legacy-4tlwHnXo.cjs';
|
|
5
5
|
import 'moment';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-
|
|
2
|
-
export { C as CouchDBToStaticPacker } from '../../index-
|
|
1
|
+
export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-CHgpWQAY.js';
|
|
2
|
+
export { C as CouchDBToStaticPacker } from '../../index-BLLT5BYE.js';
|
|
3
3
|
import '@vue-skuilder/common';
|
|
4
|
-
import '../../types-legacy-
|
|
4
|
+
import '../../types-legacy-4tlwHnXo.js';
|
|
5
5
|
import 'moment';
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.4",
|
|
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.2.
|
|
51
|
+
"@vue-skuilder/common": "0.2.4",
|
|
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": "^8.0.0",
|
|
63
63
|
"vitest": "^4.1.0"
|
|
64
64
|
},
|
|
65
|
-
"stableVersion": "0.2.
|
|
65
|
+
"stableVersion": "0.2.4"
|
|
66
66
|
}
|
|
@@ -48,7 +48,7 @@ function cardMatchesTagPattern(card: WeightedCard, pattern: string): boolean {
|
|
|
48
48
|
return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function mergeHints(allHints: Array<ReplanHints | null | undefined>): ReplanHints | undefined {
|
|
51
|
+
export function mergeHints(allHints: Array<ReplanHints | null | undefined>): ReplanHints | undefined {
|
|
52
52
|
const defined = allHints.filter((h): h is ReplanHints => h !== null && h !== undefined);
|
|
53
53
|
if (defined.length === 0) return undefined;
|
|
54
54
|
|
|
@@ -12,14 +12,16 @@ import {
|
|
|
12
12
|
StudySessionReviewItem,
|
|
13
13
|
} from '@db/impl/couch';
|
|
14
14
|
|
|
15
|
-
import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord } from '@db/core';
|
|
15
|
+
import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord, isQuestionRecord } from '@db/core';
|
|
16
16
|
import { recordUserOutcome } from '@db/core/orchestration/recording';
|
|
17
17
|
import { Loggable } from '@db/util';
|
|
18
18
|
import { getCardOrigin } from '@db/core/navigators';
|
|
19
19
|
import { ReplanHints } from '@db/core/navigators/generators/types';
|
|
20
|
+
import { mergeHints } from '@db/core/navigators/Pipeline';
|
|
20
21
|
import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
21
22
|
import { captureMixerRun } from './MixerDebugger';
|
|
22
23
|
import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
|
|
24
|
+
import { registerActiveController, type SessionDebugSnapshot, type SessionQueueDebug } from './SessionOverlay';
|
|
23
25
|
|
|
24
26
|
// ReplanHints is defined in generators/types to avoid circular dependencies.
|
|
25
27
|
// Re-exported here for backward compatibility.
|
|
@@ -35,6 +37,28 @@ export type { ReplanHints } from '@db/core/navigators/generators/types';
|
|
|
35
37
|
export interface ReplanOptions {
|
|
36
38
|
/** Scoring hints forwarded to the pipeline (boost/exclude/require). */
|
|
37
39
|
hints?: ReplanHints;
|
|
40
|
+
/**
|
|
41
|
+
* Session-durable scoring hints. Unlike `hints` (one-shot, applied to
|
|
42
|
+
* exactly the run this replan triggers), `sessionHints` are stashed on
|
|
43
|
+
* the controller and re-merged into *every* subsequent pipeline run for
|
|
44
|
+
* the remainder of the session — including the bare auto-replans
|
|
45
|
+
* (depletion/quality) that carry no caller hints, and the wedge-breaker.
|
|
46
|
+
*
|
|
47
|
+
* Use for "emphasis that should outlive a single queue rebuild" — e.g.
|
|
48
|
+
* boosting a just-failed concept tag, or a post-lesson concept boost set
|
|
49
|
+
* at session start. Without this, a one-shot `hints` boost evaporates on
|
|
50
|
+
* the next replan and the freshly-rebuilt (replace-mode) queue clobbers
|
|
51
|
+
* whatever it surfaced.
|
|
52
|
+
*
|
|
53
|
+
* Semantics (KISS): setting `sessionHints` *replaces* the prior session
|
|
54
|
+
* hints wholesale (caller beware — no accumulation, no decay). They live
|
|
55
|
+
* until session end or until explicitly overwritten. Normal usage applies
|
|
56
|
+
* a fixed boost, so repeated identical requests are no-ops.
|
|
57
|
+
*
|
|
58
|
+
* Merged with per-run `hints` via the pipeline's `mergeHints` (boosts
|
|
59
|
+
* multiply, require/exclude lists concatenate).
|
|
60
|
+
*/
|
|
61
|
+
sessionHints?: ReplanHints;
|
|
38
62
|
/**
|
|
39
63
|
* Maximum number of new cards to return from the pipeline.
|
|
40
64
|
* Default: 20 (the standard session batch size).
|
|
@@ -102,6 +126,64 @@ export interface ResponseResult {
|
|
|
102
126
|
shouldClearFeedbackShadow: boolean;
|
|
103
127
|
}
|
|
104
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Read-only snapshot of a single processed response, handed to every
|
|
131
|
+
* registered {@link OutcomeObserver} after ELO/SRS have been recorded.
|
|
132
|
+
*
|
|
133
|
+
* Only emitted for question records (non-question dismisses are skipped).
|
|
134
|
+
*/
|
|
135
|
+
export interface SessionOutcome {
|
|
136
|
+
/** The user's response. Includes `isCorrect`, `performance`, `priorAttemps`. */
|
|
137
|
+
readonly record: QuestionRecord;
|
|
138
|
+
/**
|
|
139
|
+
* The card that was answered, including its `tags` — the primary key an
|
|
140
|
+
* observer matches against (e.g. `gpc:exercise:*`). `card_elo` reflects
|
|
141
|
+
* pre-update state; the ELO write for this response is already in flight.
|
|
142
|
+
*/
|
|
143
|
+
readonly card: StudySessionRecord['card'];
|
|
144
|
+
/** The navigation decision produced for this response (read-only). */
|
|
145
|
+
readonly result: Readonly<ResponseResult>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The narrow capability surface handed to an {@link OutcomeObserver}. This is
|
|
150
|
+
* the *only* way an observer can affect the session — it cannot touch ELO,
|
|
151
|
+
* the queues, the timer, or mutate the `ResponseResult`. A misbehaving
|
|
152
|
+
* observer degrades to "wrong boost", never "corrupted session".
|
|
153
|
+
*/
|
|
154
|
+
export interface SessionControls {
|
|
155
|
+
/** Current session-durable hints, or null. For read-modify-write. */
|
|
156
|
+
getSessionHints(): ReplanHints | null;
|
|
157
|
+
/** Replace the session-durable hints wholesale (no decay). */
|
|
158
|
+
setSessionHints(hints: ReplanHints | null): void;
|
|
159
|
+
/**
|
|
160
|
+
* Merge `hints` into the existing session-durable hints via the pipeline's
|
|
161
|
+
* `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
|
|
162
|
+
* Convenience for the common "add a boost on top of what's there" case.
|
|
163
|
+
* Note: multiplicative + no decay — clamp boost factors yourself if a
|
|
164
|
+
* repeatedly-failed tag could compound unboundedly.
|
|
165
|
+
*/
|
|
166
|
+
mergeSessionHints(hints: ReplanHints): void;
|
|
167
|
+
/** Request a replan (e.g. `{ mode: 'merge' }` for immediate visibility). */
|
|
168
|
+
requestReplan(opts?: ReplanOptions): Promise<void>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A consumer-supplied hook invoked after each question response is processed.
|
|
173
|
+
*
|
|
174
|
+
* Fires on *every* question response (gate inside on `record.isCorrect` /
|
|
175
|
+
* `result.nextCardAction` as needed). Awaited but isolated: a throwing
|
|
176
|
+
* observer is caught and logged, never wedging the session. Keep the
|
|
177
|
+
* synchronous body cheap and `void` any long work (e.g. a triggered replan)
|
|
178
|
+
* so you don't stall navigation.
|
|
179
|
+
*
|
|
180
|
+
* Registered via `StudySessionConfig.outcomeObservers` → constructor options.
|
|
181
|
+
*/
|
|
182
|
+
export type OutcomeObserver = (
|
|
183
|
+
outcome: SessionOutcome,
|
|
184
|
+
controls: SessionControls
|
|
185
|
+
) => void | Promise<void>;
|
|
186
|
+
|
|
105
187
|
interface SessionServices {
|
|
106
188
|
response: ResponseProcessor;
|
|
107
189
|
}
|
|
@@ -155,6 +237,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
155
237
|
*/
|
|
156
238
|
private _replanPromise: Promise<void> | null = null;
|
|
157
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Reason for the replan currently executing in `_runReplan`, surfaced by the
|
|
242
|
+
* debug overlay's spinner. The caller's `opts.label` when present, else
|
|
243
|
+
* `'(auto)'`. Only meaningful while `_replanPromise` is non-null; cleared
|
|
244
|
+
* when the in-flight chain settles.
|
|
245
|
+
*/
|
|
246
|
+
private _activeReplanLabel: string | null = null;
|
|
247
|
+
|
|
158
248
|
/**
|
|
159
249
|
* Number of well-indicated new cards remaining before the queue
|
|
160
250
|
* degrades to poorly-indicated content. Decremented on each newQ
|
|
@@ -177,6 +267,35 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
177
267
|
*/
|
|
178
268
|
private _minCardsGuarantee: number = 0;
|
|
179
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Session-durable scoring hints. Re-merged into every pipeline run for
|
|
272
|
+
* the rest of the session (initial plan + every replan, including bare
|
|
273
|
+
* auto-replans and the wedge-breaker), via `_applyHintsToSources`.
|
|
274
|
+
*
|
|
275
|
+
* Set by `setSessionHints()` (e.g. session-start post-lesson boost) or by
|
|
276
|
+
* any replan carrying `ReplanOptions.sessionHints` (e.g. a just-failed
|
|
277
|
+
* concept boost). Replace semantics, no decay — lives until overwritten
|
|
278
|
+
* or session end. See `ReplanOptions.sessionHints` for rationale.
|
|
279
|
+
*
|
|
280
|
+
* Note: the controller-managed auto-excludes (current card, session
|
|
281
|
+
* record, imminent draw) are intentionally NOT folded in here — those are
|
|
282
|
+
* recomputed per-run in `_runReplan` and would otherwise go stale.
|
|
283
|
+
*/
|
|
284
|
+
private _sessionHints: ReplanHints | null = null;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Consumer-supplied hooks invoked after each question response is processed.
|
|
288
|
+
* Seeded from constructor options (threaded from
|
|
289
|
+
* `StudySessionConfig.outcomeObservers`). See {@link OutcomeObserver}.
|
|
290
|
+
*/
|
|
291
|
+
private _outcomeObservers: OutcomeObserver[] = [];
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Lazily-built, stable capability object handed to observers. Bound to
|
|
295
|
+
* `this`; constructed once so observers can rely on referential identity.
|
|
296
|
+
*/
|
|
297
|
+
private _sessionControls: SessionControls | null = null;
|
|
298
|
+
|
|
180
299
|
private startTime: Date;
|
|
181
300
|
private endTime: Date;
|
|
182
301
|
private _secondsRemaining: number;
|
|
@@ -219,7 +338,11 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
219
338
|
dataLayer: DataLayerProvider,
|
|
220
339
|
getViewComponent: (viewId: string) => TView,
|
|
221
340
|
mixer?: SourceMixer,
|
|
222
|
-
options?: {
|
|
341
|
+
options?: {
|
|
342
|
+
defaultBatchLimit?: number;
|
|
343
|
+
initialReviewCap?: number;
|
|
344
|
+
outcomeObservers?: OutcomeObserver[];
|
|
345
|
+
}
|
|
223
346
|
) {
|
|
224
347
|
super();
|
|
225
348
|
|
|
@@ -249,12 +372,20 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
249
372
|
if (options?.initialReviewCap !== undefined) {
|
|
250
373
|
this._initialReviewCap = options.initialReviewCap;
|
|
251
374
|
}
|
|
375
|
+
if (options?.outcomeObservers?.length) {
|
|
376
|
+
this._outcomeObservers = [...options.outcomeObservers];
|
|
377
|
+
}
|
|
252
378
|
|
|
253
379
|
this.log(`Session constructed:
|
|
254
380
|
startTime: ${this.startTime}
|
|
255
381
|
endTime: ${this.endTime}
|
|
256
382
|
defaultBatchLimit: ${this._defaultBatchLimit}
|
|
257
383
|
initialReviewCap: ${this._initialReviewCap}`);
|
|
384
|
+
|
|
385
|
+
// Expose this (now the most-recently-constructed) controller to the debug
|
|
386
|
+
// overlay (window.skuilder.session.dbgOverlay()). A new session overwrites
|
|
387
|
+
// the prior handle; no-op overhead when the overlay is never opened.
|
|
388
|
+
registerActiveController(this);
|
|
258
389
|
}
|
|
259
390
|
|
|
260
391
|
private tick() {
|
|
@@ -383,17 +514,33 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
383
514
|
.catch(() => undefined)
|
|
384
515
|
.then(() => this._runReplan(opts));
|
|
385
516
|
|
|
386
|
-
|
|
387
|
-
|
|
517
|
+
// Compare against the promise we actually store. `.finally()` returns a
|
|
518
|
+
// NEW promise, so guarding on `=== queued` (the pre-finally promise) never
|
|
519
|
+
// matches and would leak _replanPromise. `tracked` is read only inside the
|
|
520
|
+
// async callback (after init), so the self-reference is safe.
|
|
521
|
+
const tracked: Promise<void> = queued.finally(() => {
|
|
522
|
+
if (this._replanPromise === tracked) {
|
|
523
|
+
this._replanPromise = null;
|
|
524
|
+
this._activeReplanLabel = null;
|
|
525
|
+
}
|
|
388
526
|
});
|
|
527
|
+
this._replanPromise = tracked;
|
|
389
528
|
|
|
390
529
|
return queued;
|
|
391
530
|
}
|
|
392
531
|
|
|
393
532
|
const run = this._runReplan(opts);
|
|
394
|
-
|
|
395
|
-
|
|
533
|
+
// Compare against the wrapped promise we store, not `run` — `.finally()`
|
|
534
|
+
// returns a new promise, so `=== run` never matches and _replanPromise
|
|
535
|
+
// would never clear (perpetual "replan in progress"). Safe self-reference:
|
|
536
|
+
// `tracked` is read only in the async callback, after initialization.
|
|
537
|
+
const tracked: Promise<void> = run.finally(() => {
|
|
538
|
+
if (this._replanPromise === tracked) {
|
|
539
|
+
this._replanPromise = null;
|
|
540
|
+
this._activeReplanLabel = null;
|
|
541
|
+
}
|
|
396
542
|
});
|
|
543
|
+
this._replanPromise = tracked;
|
|
397
544
|
|
|
398
545
|
await run;
|
|
399
546
|
}
|
|
@@ -404,11 +551,17 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
404
551
|
* triggers in nextCard) return false and may coalesce.
|
|
405
552
|
*/
|
|
406
553
|
private _replanHasIntent(opts: ReplanOptions): boolean {
|
|
407
|
-
|
|
554
|
+
// NOTE: `label` is intentionally NOT an intent signal. It is observability-
|
|
555
|
+
// only metadata (debug overlay spinner, log tags, Pipeline strategy names),
|
|
556
|
+
// so labelling a replan must never change scheduling. Intent is strictly
|
|
557
|
+
// "does this replan carry scheduling-relevant options". This lets the
|
|
558
|
+
// unlabeled-but-named auto-replans (auto:depletion / auto:quality) keep
|
|
559
|
+
// coalescing while still showing a reason in the overlay.
|
|
408
560
|
if (opts.limit !== undefined) return true;
|
|
409
561
|
if (opts.minFollowUpCards !== undefined) return true;
|
|
410
562
|
if (opts.mode && opts.mode !== 'replace') return true;
|
|
411
563
|
if (opts.hints && Object.keys(opts.hints).length > 0) return true;
|
|
564
|
+
if (opts.sessionHints !== undefined) return true;
|
|
412
565
|
return false;
|
|
413
566
|
}
|
|
414
567
|
|
|
@@ -425,6 +578,11 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
425
578
|
* newQ.peek(0) is the imminent draw we need to exclude.
|
|
426
579
|
*/
|
|
427
580
|
private async _runReplan(opts: ReplanOptions): Promise<void> {
|
|
581
|
+
// Surface the executing replan's reason to the debug overlay spinner.
|
|
582
|
+
// `label` is observability-only (see _replanHasIntent); '(auto)' covers any
|
|
583
|
+
// unlabeled path.
|
|
584
|
+
this._activeReplanLabel = opts.label ?? '(auto)';
|
|
585
|
+
|
|
428
586
|
// Exclude all cards already presented this session. The pipeline may
|
|
429
587
|
// not yet see their encounter records (async writes), so without this
|
|
430
588
|
// they can re-enter newQ via replaceAll and cause duplicates.
|
|
@@ -455,17 +613,21 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
455
613
|
|
|
456
614
|
hints.excludeCards = [...excludeSet];
|
|
457
615
|
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
616
|
+
// Replace session-durable hints if this replan carries them. KISS:
|
|
617
|
+
// wholesale replace, no accumulation/decay (see ReplanOptions.sessionHints).
|
|
618
|
+
if (opts.sessionHints !== undefined) {
|
|
619
|
+
this._sessionHints = opts.sessionHints;
|
|
620
|
+
this.log(
|
|
621
|
+
`[Replan] Session hints ${opts.sessionHints ? 'set' : 'cleared'}: ` +
|
|
622
|
+
`${JSON.stringify(opts.sessionHints)}`
|
|
623
|
+
);
|
|
467
624
|
}
|
|
468
625
|
|
|
626
|
+
// Forward hints to all sources (CourseDB stashes them, Pipeline consumes
|
|
627
|
+
// them). The one-shot `opts.hints` are merged with the durable
|
|
628
|
+
// `_sessionHints` so session emphasis survives this and every later run.
|
|
629
|
+
this._applyHintsToSources(opts.hints, opts.label);
|
|
630
|
+
|
|
469
631
|
const labelTag = opts.label ? ` [${opts.label}]` : '';
|
|
470
632
|
this.log(
|
|
471
633
|
`Mid-session replan requested${labelTag}` +
|
|
@@ -487,6 +649,146 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
487
649
|
// );
|
|
488
650
|
}
|
|
489
651
|
|
|
652
|
+
/**
|
|
653
|
+
* Set the session-durable scoring hints (replace semantics, no decay).
|
|
654
|
+
*
|
|
655
|
+
* Unlike a one-shot replan hint, these are re-merged into every pipeline
|
|
656
|
+
* run for the rest of the session — including the initial plan when set
|
|
657
|
+
* before `prepareSession()`, every replan, the bare auto-replans, and the
|
|
658
|
+
* wedge-breaker. Pass `null` to clear.
|
|
659
|
+
*
|
|
660
|
+
* Typical callers:
|
|
661
|
+
* - `StudySession` at session start, threading `StudySessionConfig.initHints`
|
|
662
|
+
* (e.g. a post-lesson concept boost) — so the boost outlives the first
|
|
663
|
+
* queue rebuild instead of being clobbered by the first auto-replan.
|
|
664
|
+
* - A consumer view on a failure, boosting the just-failed concept tag.
|
|
665
|
+
*
|
|
666
|
+
* Does not itself trigger a replan; the next plan/replan picks it up.
|
|
667
|
+
*/
|
|
668
|
+
public setSessionHints(hints: ReplanHints | null): void {
|
|
669
|
+
this._sessionHints = hints;
|
|
670
|
+
this.log(`Session hints ${hints ? 'set' : 'cleared'}: ${JSON.stringify(hints)}`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Read the current session-durable hints (for read-modify-write callers,
|
|
675
|
+
* e.g. an outcome observer that clamps a compounding boost).
|
|
676
|
+
*/
|
|
677
|
+
public getSessionHints(): ReplanHints | null {
|
|
678
|
+
return this._sessionHints;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Live state snapshot for the debug overlay (window.skuilder.session
|
|
683
|
+
* .dbgOverlay()). Reads directly from the private queues and hints, so it
|
|
684
|
+
* always reflects the current moment — unlike the passive SessionDebugger
|
|
685
|
+
* snapshots, which only capture what was explicitly pushed to them.
|
|
686
|
+
*/
|
|
687
|
+
public getDebugSnapshot(): SessionDebugSnapshot {
|
|
688
|
+
const describe = <T extends { cardID: string }>(q: ItemQueue<T>): SessionQueueDebug => {
|
|
689
|
+
const cards: string[] = [];
|
|
690
|
+
for (let i = 0; i < q.length; i++) {
|
|
691
|
+
cards.push(q.peek(i).cardID);
|
|
692
|
+
}
|
|
693
|
+
return { length: q.length, dequeueCount: q.dequeueCount, cards };
|
|
694
|
+
};
|
|
695
|
+
return {
|
|
696
|
+
secondsRemaining: this.secondsRemaining,
|
|
697
|
+
hasCardGuarantee: this.hasCardGuarantee,
|
|
698
|
+
minCardsGuarantee: this._minCardsGuarantee,
|
|
699
|
+
wellIndicatedRemaining: this._wellIndicatedRemaining,
|
|
700
|
+
currentCard: this._currentCard?.item.cardID ?? null,
|
|
701
|
+
sessionHints: this._sessionHints,
|
|
702
|
+
replanActive: this._replanPromise !== null,
|
|
703
|
+
replanLabel: this._activeReplanLabel,
|
|
704
|
+
reviewQ: describe(this.reviewQ),
|
|
705
|
+
newQ: describe(this.newQ),
|
|
706
|
+
failedQ: describe(this.failedQ),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Merge `hints` into the durable session hints via the pipeline's
|
|
712
|
+
* `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
|
|
713
|
+
* Convenience over get-then-set for the common additive case. Note the
|
|
714
|
+
* multiplicative, no-decay semantics — clamp boost factors at the call
|
|
715
|
+
* site if a repeatedly-emphasised tag could compound unboundedly.
|
|
716
|
+
*/
|
|
717
|
+
public mergeSessionHints(hints: ReplanHints): void {
|
|
718
|
+
this._sessionHints = mergeHints([this._sessionHints, hints]) ?? null;
|
|
719
|
+
this.log(`Session hints merged: ${JSON.stringify(this._sessionHints)}`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Merge the durable `_sessionHints` with this run's one-shot hints and
|
|
724
|
+
* push the result to every source for consumption on the next pipeline
|
|
725
|
+
* run. Centralised so the initial plan and all replan paths apply session
|
|
726
|
+
* emphasis identically. No-op when there are no hints of either kind.
|
|
727
|
+
*/
|
|
728
|
+
private _applyHintsToSources(oneShot?: ReplanHints, label?: string): void {
|
|
729
|
+
// Thread the provenance label into the one-shot layer; mergeHints will
|
|
730
|
+
// fold it into the combined `_label`.
|
|
731
|
+
const oneShotWithLabel: ReplanHints | undefined =
|
|
732
|
+
oneShot && label ? { ...oneShot, _label: label } : oneShot;
|
|
733
|
+
|
|
734
|
+
const merged = mergeHints([this._sessionHints, oneShotWithLabel]);
|
|
735
|
+
if (!merged) return;
|
|
736
|
+
|
|
737
|
+
for (const source of this.sources) {
|
|
738
|
+
source.setEphemeralHints?.(merged);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Build (once) the stable capability object handed to outcome observers.
|
|
744
|
+
* Methods are bound to `this`; the object identity is stable across calls
|
|
745
|
+
* so observers may key off it.
|
|
746
|
+
*/
|
|
747
|
+
private _getSessionControls(): SessionControls {
|
|
748
|
+
if (!this._sessionControls) {
|
|
749
|
+
this._sessionControls = {
|
|
750
|
+
getSessionHints: () => this.getSessionHints(),
|
|
751
|
+
setSessionHints: (h) => this.setSessionHints(h),
|
|
752
|
+
mergeSessionHints: (h) => this.mergeSessionHints(h),
|
|
753
|
+
requestReplan: (opts) => this.requestReplan(opts),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
return this._sessionControls;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Notify registered outcome observers about a processed response.
|
|
761
|
+
*
|
|
762
|
+
* Only question records are surfaced (non-question dismisses are skipped).
|
|
763
|
+
* Observers run after ELO/SRS are recorded and before navigation. Each is
|
|
764
|
+
* awaited but isolated in try/catch — a throwing observer is logged and
|
|
765
|
+
* skipped, never wedging the session. Keep observers cheap and `void` any
|
|
766
|
+
* long work (e.g. a triggered replan) to avoid stalling the draw.
|
|
767
|
+
*/
|
|
768
|
+
private async _notifyOutcomeObservers(
|
|
769
|
+
record: CardRecord,
|
|
770
|
+
currentCard: StudySessionRecord,
|
|
771
|
+
result: ResponseResult
|
|
772
|
+
): Promise<void> {
|
|
773
|
+
if (this._outcomeObservers.length === 0) return;
|
|
774
|
+
if (!isQuestionRecord(record)) return;
|
|
775
|
+
|
|
776
|
+
const outcome: SessionOutcome = {
|
|
777
|
+
record,
|
|
778
|
+
card: currentCard.card,
|
|
779
|
+
result,
|
|
780
|
+
};
|
|
781
|
+
const controls = this._getSessionControls();
|
|
782
|
+
|
|
783
|
+
for (const observer of this._outcomeObservers) {
|
|
784
|
+
try {
|
|
785
|
+
await observer(outcome, controls);
|
|
786
|
+
} catch (e) {
|
|
787
|
+
this.error('[OutcomeObserver] observer threw; ignoring', e);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
490
792
|
/**
|
|
491
793
|
* Run a replan, bypassing requestReplan()'s coalesce logic.
|
|
492
794
|
*
|
|
@@ -501,9 +803,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
501
803
|
*/
|
|
502
804
|
private async _replanUncoalesced(opts: ReplanOptions): Promise<void> {
|
|
503
805
|
const run = this._runReplan(opts);
|
|
504
|
-
|
|
505
|
-
|
|
806
|
+
// See requestReplan: guard against the wrapped promise we store, not `run`.
|
|
807
|
+
const tracked: Promise<void> = run.finally(() => {
|
|
808
|
+
if (this._replanPromise === tracked) {
|
|
809
|
+
this._replanPromise = null;
|
|
810
|
+
this._activeReplanLabel = null;
|
|
811
|
+
}
|
|
506
812
|
});
|
|
813
|
+
this._replanPromise = tracked;
|
|
507
814
|
await run;
|
|
508
815
|
}
|
|
509
816
|
|
|
@@ -519,7 +826,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
519
826
|
if (!input) return {};
|
|
520
827
|
|
|
521
828
|
// If the input has any ReplanOptions-specific key, treat it as ReplanOptions
|
|
522
|
-
const replanKeys = ['hints', 'limit', 'mode', 'label', 'minFollowUpCards'];
|
|
829
|
+
const replanKeys = ['hints', 'sessionHints', 'limit', 'mode', 'label', 'minFollowUpCards'];
|
|
523
830
|
const inputKeys = Object.keys(input);
|
|
524
831
|
if (inputKeys.some((k) => replanKeys.includes(k))) {
|
|
525
832
|
return input as ReplanOptions;
|
|
@@ -706,6 +1013,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
706
1013
|
// never touch reviewQ, so the inflation is unnecessary there.
|
|
707
1014
|
const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
|
|
708
1015
|
|
|
1016
|
+
// Initial plan: push session-durable hints to sources so the very first
|
|
1017
|
+
// pipeline run reflects them (e.g. a post-lesson boost). Replans push
|
|
1018
|
+
// their own session+one-shot merge via _runReplan before reaching here,
|
|
1019
|
+
// so we must NOT re-apply here or we'd drop their per-run excludeCards.
|
|
1020
|
+
if (!replan) {
|
|
1021
|
+
this._applyHintsToSources();
|
|
1022
|
+
}
|
|
1023
|
+
|
|
709
1024
|
// Collect batches from each source
|
|
710
1025
|
const batches: SourceBatch[] = [];
|
|
711
1026
|
|
|
@@ -1031,7 +1346,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1031
1346
|
`(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
|
|
1032
1347
|
`Triggering background replan.`
|
|
1033
1348
|
);
|
|
1034
|
-
|
|
1349
|
+
// label is observability-only (overlay/logs); does not affect coalescing.
|
|
1350
|
+
void this.requestReplan({ label: 'auto:depletion' });
|
|
1035
1351
|
}
|
|
1036
1352
|
|
|
1037
1353
|
// Opportunistic quality: few well-indicated cards remain.
|
|
@@ -1047,7 +1363,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1047
1363
|
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
|
|
1048
1364
|
`(newQ: ${this.newQ.length}). Triggering background replan.`
|
|
1049
1365
|
);
|
|
1050
|
-
|
|
1366
|
+
// label is observability-only (overlay/logs); does not affect coalescing.
|
|
1367
|
+
void this.requestReplan({ label: 'auto:quality' });
|
|
1051
1368
|
}
|
|
1052
1369
|
|
|
1053
1370
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
@@ -1192,7 +1509,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1192
1509
|
...currentCard.item,
|
|
1193
1510
|
};
|
|
1194
1511
|
|
|
1195
|
-
|
|
1512
|
+
const result = await this.services.response.processResponse(
|
|
1196
1513
|
cardRecord,
|
|
1197
1514
|
cardHistory,
|
|
1198
1515
|
studySessionItem,
|
|
@@ -1204,6 +1521,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1204
1521
|
maxSessionViews,
|
|
1205
1522
|
sessionViews
|
|
1206
1523
|
);
|
|
1524
|
+
|
|
1525
|
+
// Surface the processed outcome to any registered observers (e.g. a
|
|
1526
|
+
// difficulty-booster that bumps session hints on a failed exercise tag).
|
|
1527
|
+
// Runs after ELO/SRS recording, before the caller navigates. Isolated so
|
|
1528
|
+
// a faulty observer can't break response handling.
|
|
1529
|
+
await this._notifyOutcomeObservers(cardRecord, currentCard, result);
|
|
1530
|
+
|
|
1531
|
+
return result;
|
|
1207
1532
|
}
|
|
1208
1533
|
|
|
1209
1534
|
private dismissCurrentCard(action: SessionAction = 'dismiss-success') {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { logger } from '../util/logger';
|
|
2
2
|
import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
|
|
3
|
+
import { toggleSessionOverlay } from './SessionOverlay';
|
|
3
4
|
|
|
4
5
|
// ============================================================================
|
|
5
6
|
// SESSION DEBUGGER
|
|
@@ -344,6 +345,14 @@ export const sessionDebugAPI = {
|
|
|
344
345
|
showCurrentQueue();
|
|
345
346
|
},
|
|
346
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Toggle the pinned, live-updating DOM overlay for the active controller
|
|
350
|
+
* (queues, session hints, timer). No-ops in non-browser hosts.
|
|
351
|
+
*/
|
|
352
|
+
dbgOverlay(): void {
|
|
353
|
+
toggleSessionOverlay();
|
|
354
|
+
},
|
|
355
|
+
|
|
347
356
|
/**
|
|
348
357
|
* Show presentation history for current or past session.
|
|
349
358
|
*/
|
|
@@ -413,6 +422,7 @@ export const sessionDebugAPI = {
|
|
|
413
422
|
🎯 Session Debug API
|
|
414
423
|
|
|
415
424
|
Commands:
|
|
425
|
+
.dbgOverlay() Toggle the pinned live overlay (queues, hints, timer)
|
|
416
426
|
.showQueue() Show current queue state (active session only)
|
|
417
427
|
.showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
|
|
418
428
|
.showInterleaving(index?) Analyze course interleaving pattern
|