@vue-skuilder/db 0.2.4 → 0.2.5
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 +5 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +5 -4
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +5 -4
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +5 -4
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +5 -4
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +5 -4
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +58 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +207 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +207 -19
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/navigators/generators/elo.ts +32 -11
- package/src/study/SessionController.ts +104 -3
- package/src/study/SessionOverlay.ts +245 -21
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.5",
|
|
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.5",
|
|
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.5"
|
|
66
66
|
}
|
|
@@ -7,6 +7,13 @@ import type { QualifiedCardID } from '../..';
|
|
|
7
7
|
import type { CardGenerator, GeneratorContext, GeneratorResult } from './types';
|
|
8
8
|
import { logger } from '@db/util/logger';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Std-dev (in ELO points) of the Gaussian that converts card↔user ELO distance
|
|
12
|
+
* into a relevance weight. 300 reproduces the legacy linear ramp's half-weight
|
|
13
|
+
* point (distance 250 → ~0.5) while removing its hard zero beyond distance 500.
|
|
14
|
+
*/
|
|
15
|
+
const ELO_RELEVANCE_SIGMA = 300;
|
|
16
|
+
|
|
10
17
|
// ============================================================================
|
|
11
18
|
// ELO NAVIGATOR
|
|
12
19
|
// ============================================================================
|
|
@@ -96,17 +103,31 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
96
103
|
// `[active=${activeCards.length} candidates=${newCards.length}]`
|
|
97
104
|
// );
|
|
98
105
|
|
|
99
|
-
// Score new cards by ELO
|
|
100
|
-
//
|
|
106
|
+
// Score new cards by ELO proximity, then apply bounded multiplicative
|
|
107
|
+
// jitter for session-to-session variety.
|
|
101
108
|
//
|
|
102
|
-
//
|
|
109
|
+
// relevance = exp(-(distance / SIGMA)^2) // Gaussian: smooth, always > 0
|
|
110
|
+
// score = relevance * (0.5 + 0.5 * U) // U ~ Uniform(0, 1)
|
|
103
111
|
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
112
|
+
// This replaces the legacy `rawScore = max(0, 1 - distance/500)` ramp +
|
|
113
|
+
// Efraimidis-Spirakis key `U^(1/rawScore)`, which introduced two
|
|
114
|
+
// discontinuities that defeated downstream replan boosts:
|
|
115
|
+
// 1. The ramp's clamp made every card ≥500 ELO from the user a HARD zero.
|
|
116
|
+
// The pipeline DELETES zero-score cards (filter score>0) *before* boosts
|
|
117
|
+
// are applied, so no boost could resurface an under-ELO'd target — e.g.
|
|
118
|
+
// a freshly-introduced grapheme sitting ~475 below an inflated global
|
|
119
|
+
// ELO. (See packages/db/docs/todo-intro-concept-emphasis-and-retrieval.md.)
|
|
120
|
+
// 2. The A-Res key `U^(1/rawScore)` ALSO manufactured effective zeros: as
|
|
121
|
+
// rawScore→0 the exponent explodes and `U^huge` underflows to 0, with
|
|
122
|
+
// wild variance just above it — so a downstream boost multiplied a
|
|
123
|
+
// lottery ticket rather than a stable relevance.
|
|
108
124
|
//
|
|
109
|
-
//
|
|
125
|
+
// Gaussian relevance never hits zero (no cliff, survives the score>0 filter,
|
|
126
|
+
// so a boost can always lift a low-ELO target), and the [0.5, 1] jitter keeps
|
|
127
|
+
// ELO ordering up to a 2× factor while still shuffling near-equal cards so the
|
|
128
|
+
// same cards don't loop every session. SIGMA=300 reproduces the old ramp's
|
|
129
|
+
// half-weight point (distance 250 → ~0.5), leaving center-of-range difficulty
|
|
130
|
+
// matching unchanged.
|
|
110
131
|
//
|
|
111
132
|
// Card ELO is read from the pooled `.elo` carried on each candidate by
|
|
112
133
|
// getCardsCenteredAtELO — verified equal to a separate getCardEloData()
|
|
@@ -115,8 +136,8 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
115
136
|
const cardElo = c.elo ?? 1000;
|
|
116
137
|
|
|
117
138
|
const distance = Math.abs(cardElo - userGlobalElo);
|
|
118
|
-
const
|
|
119
|
-
const samplingKey =
|
|
139
|
+
const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
|
|
140
|
+
const samplingKey = relevance * (0.5 + 0.5 * Math.random());
|
|
120
141
|
|
|
121
142
|
return {
|
|
122
143
|
cardId: c.cardID,
|
|
@@ -129,7 +150,7 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
129
150
|
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-ELO-default',
|
|
130
151
|
action: 'generated',
|
|
131
152
|
score: samplingKey,
|
|
132
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}),
|
|
153
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`,
|
|
133
154
|
},
|
|
134
155
|
],
|
|
135
156
|
};
|
|
@@ -21,7 +21,12 @@ import { mergeHints } from '@db/core/navigators/Pipeline';
|
|
|
21
21
|
import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
22
22
|
import { captureMixerRun } from './MixerDebugger';
|
|
23
23
|
import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
registerActiveController,
|
|
26
|
+
type SessionDebugSnapshot,
|
|
27
|
+
type SessionDrawnCardDebug,
|
|
28
|
+
type SessionQueueDebug,
|
|
29
|
+
} from './SessionOverlay';
|
|
25
30
|
|
|
26
31
|
// ReplanHints is defined in generators/types to avoid circular dependencies.
|
|
27
32
|
// Re-exported here for backward compatibility.
|
|
@@ -59,6 +64,21 @@ export interface ReplanOptions {
|
|
|
59
64
|
* multiply, require/exclude lists concatenate).
|
|
60
65
|
*/
|
|
61
66
|
sessionHints?: ReplanHints;
|
|
67
|
+
/**
|
|
68
|
+
* Like `sessionHints`, but *merged* into the existing session-durable hints
|
|
69
|
+
* (via `mergeHints`) instead of replacing them. Use when emphasis should
|
|
70
|
+
* *accumulate* across replans rather than clobber — e.g. introducing a second
|
|
71
|
+
* concept mid-session must not wipe the first concept's boost, nor any
|
|
72
|
+
* `difficultyBooster`/`conceptBackoff` state on other concepts.
|
|
73
|
+
*
|
|
74
|
+
* Merge semantics (see `mergeHints`): boosts MULTIPLY, require/exclude lists
|
|
75
|
+
* concat-dedup. Re-emphasising the *same* tag therefore compounds — callers
|
|
76
|
+
* boosting a tag they may have already boosted should clamp at the call site.
|
|
77
|
+
*
|
|
78
|
+
* If both `sessionHints` and `mergeSessionHints` are supplied, the replace is
|
|
79
|
+
* applied first, then the merge — but they are normally mutually exclusive.
|
|
80
|
+
*/
|
|
81
|
+
mergeSessionHints?: ReplanHints;
|
|
62
82
|
/**
|
|
63
83
|
* Maximum number of new cards to return from the pipeline.
|
|
64
84
|
* Default: 20 (the standard session batch size).
|
|
@@ -283,6 +303,22 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
283
303
|
*/
|
|
284
304
|
private _sessionHints: ReplanHints | null = null;
|
|
285
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Card IDs that have been *served* (drawn/consumed) this session. Populated
|
|
308
|
+
* at the single consumption choke-point (removeItemFromQueue), so it reflects
|
|
309
|
+
* a draw the instant it happens — earlier than `_sessionRecord`, which only
|
|
310
|
+
* lands once the card is *responded to*.
|
|
311
|
+
*
|
|
312
|
+
* Used to keep already-served cards out of newQ on every (re)plan: a `new`
|
|
313
|
+
* card shown once must never re-enter newQ this session. This is the general
|
|
314
|
+
* guard against re-presentation — including the case where a replan in flight
|
|
315
|
+
* captured a now-drawn card (e.g. a +INF require-injected follow-up the
|
|
316
|
+
* depletion prefetch grabbed just before it was drawn). Reviews/failed cards
|
|
317
|
+
* legitimately recur and are tracked by their own queues, so this only gates
|
|
318
|
+
* `new`-origin candidates.
|
|
319
|
+
*/
|
|
320
|
+
private _servedCardIds: Set<string> = new Set();
|
|
321
|
+
|
|
286
322
|
/**
|
|
287
323
|
* Consumer-supplied hooks invoked after each question response is processed.
|
|
288
324
|
* Seeded from constructor options (threaded from
|
|
@@ -562,6 +598,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
562
598
|
if (opts.mode && opts.mode !== 'replace') return true;
|
|
563
599
|
if (opts.hints && Object.keys(opts.hints).length > 0) return true;
|
|
564
600
|
if (opts.sessionHints !== undefined) return true;
|
|
601
|
+
if (opts.mergeSessionHints !== undefined) return true;
|
|
565
602
|
return false;
|
|
566
603
|
}
|
|
567
604
|
|
|
@@ -623,6 +660,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
623
660
|
);
|
|
624
661
|
}
|
|
625
662
|
|
|
663
|
+
// Additive emphasis: merge (don't clobber) into durable hints. Lets a new
|
|
664
|
+
// concept's boost accumulate on top of prior concepts' boosts and any
|
|
665
|
+
// observer-managed boost/decay state. Boosts multiply, lists concat-dedup.
|
|
666
|
+
if (opts.mergeSessionHints !== undefined) {
|
|
667
|
+
this._sessionHints = mergeHints([this._sessionHints, opts.mergeSessionHints]) ?? null;
|
|
668
|
+
this.log(`[Replan] Session hints merged: ${JSON.stringify(this._sessionHints)}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
626
671
|
// Forward hints to all sources (CourseDB stashes them, Pipeline consumes
|
|
627
672
|
// them). The one-shot `opts.hints` are merged with the durable
|
|
628
673
|
// `_sessionHints` so session emphasis survives this and every later run.
|
|
@@ -692,6 +737,16 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
692
737
|
}
|
|
693
738
|
return { length: q.length, dequeueCount: q.dequeueCount, cards };
|
|
694
739
|
};
|
|
740
|
+
const drawnCards: SessionDrawnCardDebug[] = this._sessionRecord.map((r) => {
|
|
741
|
+
const last = r.records[r.records.length - 1];
|
|
742
|
+
return {
|
|
743
|
+
cardID: r.item.cardID,
|
|
744
|
+
status: r.item.status,
|
|
745
|
+
attempts: r.records.length,
|
|
746
|
+
correct: last && isQuestionRecord(last) ? last.isCorrect : null,
|
|
747
|
+
timeSpentMs: r.records.reduce((sum, rec) => sum + rec.timeSpent, 0),
|
|
748
|
+
};
|
|
749
|
+
});
|
|
695
750
|
return {
|
|
696
751
|
secondsRemaining: this.secondsRemaining,
|
|
697
752
|
hasCardGuarantee: this.hasCardGuarantee,
|
|
@@ -704,6 +759,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
704
759
|
reviewQ: describe(this.reviewQ),
|
|
705
760
|
newQ: describe(this.newQ),
|
|
706
761
|
failedQ: describe(this.failedQ),
|
|
762
|
+
drawnCards,
|
|
707
763
|
};
|
|
708
764
|
}
|
|
709
765
|
|
|
@@ -1100,8 +1156,13 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1100
1156
|
const reviewWeighted = mixedWeighted
|
|
1101
1157
|
.filter((w) => getCardOrigin(w) === 'review')
|
|
1102
1158
|
.slice(0, this._initialReviewCap);
|
|
1159
|
+
// Proactive de-dup: a `new` card served earlier this session must never
|
|
1160
|
+
// re-enter newQ — whether it slipped back via a +INF require-injection, an
|
|
1161
|
+
// additive merge, or a stale generator candidate. This is the general guard
|
|
1162
|
+
// (see _servedCardIds); it makes re-presentation structurally impossible
|
|
1163
|
+
// rather than relying on each upstream path to exclude correctly.
|
|
1103
1164
|
const newWeighted = mixedWeighted
|
|
1104
|
-
.filter((w) => getCardOrigin(w) === 'new')
|
|
1165
|
+
.filter((w) => getCardOrigin(w) === 'new' && !this._servedCardIds.has(w.cardId))
|
|
1105
1166
|
.slice(0, newLimit);
|
|
1106
1167
|
|
|
1107
1168
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
@@ -1347,7 +1408,10 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1347
1408
|
`Triggering background replan.`
|
|
1348
1409
|
);
|
|
1349
1410
|
// label is observability-only (overlay/logs); does not affect coalescing.
|
|
1350
|
-
|
|
1411
|
+
// mode:'merge' preserves any already-queued cards (e.g. an undrawn
|
|
1412
|
+
// prescribed WST whose one-shot requireCards won't be re-asserted by this
|
|
1413
|
+
// bare replan) instead of replaceAll() wiping them. See mergeToFront.
|
|
1414
|
+
void this.requestReplan({ label: 'auto:depletion', mode: 'merge' });
|
|
1351
1415
|
}
|
|
1352
1416
|
|
|
1353
1417
|
// Opportunistic quality: few well-indicated cards remain.
|
|
@@ -1586,6 +1650,21 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1586
1650
|
* Remove an item from its source queue after consumption by nextCard().
|
|
1587
1651
|
*/
|
|
1588
1652
|
private removeItemFromQueue(item: StudySessionItem): void {
|
|
1653
|
+
// Durable-until-drawn requirements: a caller may place a card ID in the
|
|
1654
|
+
// session-durable `requireCards` (via mergeSessionHints) so every later
|
|
1655
|
+
// replan re-asserts it at +INF until it actually surfaces — e.g. a
|
|
1656
|
+
// prescribed intro follow-up that must survive the replace-mode burst/auto
|
|
1657
|
+
// replans that would otherwise clobber a one-shot requirement. The
|
|
1658
|
+
// requirement is satisfied the instant the card is drawn, so clear it here
|
|
1659
|
+
// (the single consumption choke-point) to stop it being re-injected forever
|
|
1660
|
+
// and to bound accumulation. Generic: card-ID only, no domain vocabulary.
|
|
1661
|
+
this._clearDurableRequirement(item.cardID);
|
|
1662
|
+
|
|
1663
|
+
// Record the draw immediately (earlier than _sessionRecord, which waits for
|
|
1664
|
+
// a response) so getWeightedContent can keep this card out of newQ on any
|
|
1665
|
+
// replan that lands after this draw.
|
|
1666
|
+
this._servedCardIds.add(item.cardID);
|
|
1667
|
+
|
|
1589
1668
|
// Check each queue - item should be at the front of one of them
|
|
1590
1669
|
if (this.reviewQ.peek(0)?.cardID === item.cardID) {
|
|
1591
1670
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
@@ -1599,6 +1678,28 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1599
1678
|
}
|
|
1600
1679
|
}
|
|
1601
1680
|
|
|
1681
|
+
/**
|
|
1682
|
+
* Remove a satisfied card ID from the durable session-hint `requireCards`
|
|
1683
|
+
* list. Called when a card is consumed (see removeItemFromQueue). No-op if
|
|
1684
|
+
* the card was not a durable requirement.
|
|
1685
|
+
*
|
|
1686
|
+
* Matches literal IDs only: a glob/pattern requirement (which may stand for
|
|
1687
|
+
* several cards) is NOT considered satisfied by a single draw and is left in
|
|
1688
|
+
* place — durable patterns are the caller's responsibility, one-shot `hints`
|
|
1689
|
+
* remain the right tool for them.
|
|
1690
|
+
*/
|
|
1691
|
+
private _clearDurableRequirement(cardID: string): void {
|
|
1692
|
+
const req = this._sessionHints?.requireCards;
|
|
1693
|
+
if (!req || req.length === 0) return;
|
|
1694
|
+
const next = req.filter((id) => id !== cardID);
|
|
1695
|
+
if (next.length === req.length) return; // not a durable requirement
|
|
1696
|
+
this._sessionHints = {
|
|
1697
|
+
...this._sessionHints!,
|
|
1698
|
+
requireCards: next.length > 0 ? next : undefined,
|
|
1699
|
+
};
|
|
1700
|
+
this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1602
1703
|
/**
|
|
1603
1704
|
* End the session and record learning outcomes.
|
|
1604
1705
|
*
|
|
@@ -25,6 +25,22 @@ export interface SessionQueueDebug {
|
|
|
25
25
|
cards: string[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A card the learner has interacted with this session (one entry per card in
|
|
30
|
+
* the session record, regardless of which queue — if any — still holds it).
|
|
31
|
+
*/
|
|
32
|
+
export interface SessionDrawnCardDebug {
|
|
33
|
+
cardID: string;
|
|
34
|
+
/** Queue status at draw time: 'new' | 'review' | 'failed-new' | 'failed-review'. */
|
|
35
|
+
status: string;
|
|
36
|
+
/** Number of CardRecords logged for this card this session (≥1). */
|
|
37
|
+
attempts: number;
|
|
38
|
+
/** Latest record's correctness; null for non-question (info) records. */
|
|
39
|
+
correct: boolean | null;
|
|
40
|
+
/** Total time spent across all of this card's records, in ms. */
|
|
41
|
+
timeSpentMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
/** Live snapshot of the controller, read fresh on each overlay tick. */
|
|
29
45
|
export interface SessionDebugSnapshot {
|
|
30
46
|
secondsRemaining: number;
|
|
@@ -42,6 +58,8 @@ export interface SessionDebugSnapshot {
|
|
|
42
58
|
reviewQ: SessionQueueDebug;
|
|
43
59
|
newQ: SessionQueueDebug;
|
|
44
60
|
failedQ: SessionQueueDebug;
|
|
61
|
+
/** Every card the learner has interacted with this session, draw order. */
|
|
62
|
+
drawnCards: SessionDrawnCardDebug[];
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
/** The narrow surface the overlay needs from a SessionController. */
|
|
@@ -75,7 +93,11 @@ export function getActiveController(): SessionDebugTarget | null {
|
|
|
75
93
|
|
|
76
94
|
const OVERLAY_ID = 'skuilder-session-overlay';
|
|
77
95
|
const POLL_MS = 300;
|
|
78
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Cap on how many cards a queue lists by default. Queues at or below this show
|
|
98
|
+
* in full; larger ones show the first INLINE_THRESHOLD then a clickable
|
|
99
|
+
* "… +N more" affordance that expands to the full list (and back).
|
|
100
|
+
*/
|
|
79
101
|
const INLINE_THRESHOLD = 5;
|
|
80
102
|
|
|
81
103
|
/** Braille spinner frames, advanced once per render tick (≈POLL_MS cadence). */
|
|
@@ -85,8 +107,29 @@ let spinnerFrame = 0;
|
|
|
85
107
|
let overlayEl: HTMLElement | null = null;
|
|
86
108
|
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
|
87
109
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Most recent snapshot rendered, retained so the click-to-copy button can
|
|
112
|
+
* serialise exactly what is on screen at click time (decoupled from the poll).
|
|
113
|
+
*/
|
|
114
|
+
let lastSnapshot: SessionDebugSnapshot | null = null;
|
|
115
|
+
|
|
116
|
+
/** Epoch ms until which the copy button shows its "copied" confirmation. */
|
|
117
|
+
let copyFlashUntil = 0;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* When minified, the overlay collapses to just its header bar (title + copy +
|
|
121
|
+
* restore button), suppressing all body sections. The poll keeps running so
|
|
122
|
+
* `lastSnapshot` — and therefore the copy button — stays current underneath.
|
|
123
|
+
*/
|
|
124
|
+
let minified = false;
|
|
125
|
+
|
|
126
|
+
/** Expansion state for collapsible (large) lists, preserved across re-renders. */
|
|
127
|
+
const expanded: Record<string, boolean> = {
|
|
128
|
+
reviewQ: false,
|
|
129
|
+
newQ: false,
|
|
130
|
+
failedQ: false,
|
|
131
|
+
drawn: false,
|
|
132
|
+
};
|
|
90
133
|
|
|
91
134
|
/**
|
|
92
135
|
* Toggle the pinned overlay on/off. No-ops (with a console hint) when there is
|
|
@@ -107,6 +150,7 @@ export function toggleSessionOverlay(): void {
|
|
|
107
150
|
}
|
|
108
151
|
|
|
109
152
|
function mount(): void {
|
|
153
|
+
minified = false;
|
|
110
154
|
overlayEl = document.createElement('div');
|
|
111
155
|
overlayEl.id = OVERLAY_ID;
|
|
112
156
|
Object.assign(overlayEl.style, {
|
|
@@ -149,11 +193,22 @@ function render(): void {
|
|
|
149
193
|
|
|
150
194
|
const ctrl = getActiveController();
|
|
151
195
|
if (!ctrl) {
|
|
152
|
-
|
|
196
|
+
lastSnapshot = null;
|
|
197
|
+
overlayEl.innerHTML =
|
|
198
|
+
headerHtml() + (minified ? '' : `<div style="opacity:.65">No active session.</div>`);
|
|
199
|
+
attachHandlers();
|
|
153
200
|
return;
|
|
154
201
|
}
|
|
155
202
|
|
|
156
203
|
const s = ctrl.getDebugSnapshot();
|
|
204
|
+
lastSnapshot = s;
|
|
205
|
+
|
|
206
|
+
if (minified) {
|
|
207
|
+
overlayEl.innerHTML = headerHtml();
|
|
208
|
+
attachHandlers();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
157
212
|
overlayEl.innerHTML =
|
|
158
213
|
headerHtml() +
|
|
159
214
|
replanHtml(s) +
|
|
@@ -161,9 +216,17 @@ function render(): void {
|
|
|
161
216
|
hintsHtml(s.sessionHints) +
|
|
162
217
|
queueHtml('reviewQ', 'reviewQ', s.reviewQ) +
|
|
163
218
|
queueHtml('newQ', 'newQ', s.newQ) +
|
|
164
|
-
queueHtml('failedQ', 'failedQ', s.failedQ)
|
|
219
|
+
queueHtml('failedQ', 'failedQ', s.failedQ) +
|
|
220
|
+
drawnHtml('drawn', s.drawnCards);
|
|
221
|
+
|
|
222
|
+
attachHandlers();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** (Re-)bind click handlers after each innerHTML rewrite. */
|
|
226
|
+
function attachHandlers(): void {
|
|
227
|
+
if (!overlayEl) return;
|
|
165
228
|
|
|
166
|
-
//
|
|
229
|
+
// Toggle handlers for collapsible queue / drawn-list headers and footers.
|
|
167
230
|
overlayEl.querySelectorAll<HTMLElement>('[data-q]').forEach((el) => {
|
|
168
231
|
el.onclick = () => {
|
|
169
232
|
const key = el.dataset.q;
|
|
@@ -172,10 +235,61 @@ function render(): void {
|
|
|
172
235
|
render();
|
|
173
236
|
};
|
|
174
237
|
});
|
|
238
|
+
|
|
239
|
+
// Global click-to-copy: dump the currently-displayed snapshot as plain text.
|
|
240
|
+
const copyBtn = overlayEl.querySelector<HTMLElement>('[data-copy]');
|
|
241
|
+
if (copyBtn) {
|
|
242
|
+
copyBtn.onclick = (ev) => {
|
|
243
|
+
ev.stopPropagation();
|
|
244
|
+
copySnapshot();
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Minify / restore: collapse the overlay to its header bar and back.
|
|
249
|
+
const minBtn = overlayEl.querySelector<HTMLElement>('[data-min]');
|
|
250
|
+
if (minBtn) {
|
|
251
|
+
minBtn.onclick = (ev) => {
|
|
252
|
+
ev.stopPropagation();
|
|
253
|
+
minified = !minified;
|
|
254
|
+
render();
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Serialise the on-screen snapshot to the clipboard, with a transient flash. */
|
|
260
|
+
function copySnapshot(): void {
|
|
261
|
+
const text = snapshotToText(lastSnapshot);
|
|
262
|
+
const flash = () => {
|
|
263
|
+
copyFlashUntil = Date.now() + 1200;
|
|
264
|
+
render();
|
|
265
|
+
};
|
|
266
|
+
const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
|
|
267
|
+
if (clip?.writeText) {
|
|
268
|
+
clip.writeText(text).then(flash, (err) => {
|
|
269
|
+
logger.warn(`[Session Overlay] Clipboard write failed: ${String(err)}`);
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
logger.info(`[Session Overlay] Clipboard unavailable; snapshot follows:\n${text}`);
|
|
273
|
+
}
|
|
175
274
|
}
|
|
176
275
|
|
|
177
276
|
function headerHtml(): string {
|
|
178
|
-
|
|
277
|
+
const flashing = Date.now() < copyFlashUntil;
|
|
278
|
+
const btnLabel = flashing ? '✓ copied' : '⎘ copy';
|
|
279
|
+
const btnColor = flashing ? '#86efac' : '#93c5fd';
|
|
280
|
+
const copyBtn =
|
|
281
|
+
`<span data-copy style="cursor:pointer;float:right;font-weight:400;` +
|
|
282
|
+
`color:${btnColor};border:1px solid currentColor;border-radius:4px;` +
|
|
283
|
+
`padding:0 4px;line-height:1.3">${btnLabel}</span>`;
|
|
284
|
+
const minBtn =
|
|
285
|
+
`<span data-min title="${minified ? 'Restore' : 'Minify'}" ` +
|
|
286
|
+
`style="cursor:pointer;float:right;font-weight:400;color:#93c5fd;` +
|
|
287
|
+
`border:1px solid currentColor;border-radius:4px;padding:0 5px;` +
|
|
288
|
+
`margin-right:4px;line-height:1.3">${minified ? '▢' : '—'}</span>`;
|
|
289
|
+
return (
|
|
290
|
+
`<div style="font-weight:600;color:#93c5fd;margin-bottom:${minified ? 0 : 4}px">` +
|
|
291
|
+
`${copyBtn}${minBtn}⚙ SessionController</div>`
|
|
292
|
+
);
|
|
179
293
|
}
|
|
180
294
|
|
|
181
295
|
function replanHtml(s: SessionDebugSnapshot): string {
|
|
@@ -238,29 +352,139 @@ function hintsHtml(h: ReplanHints | null): string {
|
|
|
238
352
|
|
|
239
353
|
function queueHtml(key: string, label: string, q: SessionQueueDebug): string {
|
|
240
354
|
const collapsible = q.length > INLINE_THRESHOLD;
|
|
241
|
-
const isOpen =
|
|
355
|
+
const isOpen = collapsible && expanded[key];
|
|
242
356
|
const caret = collapsible ? (expanded[key] ? '▾ ' : '▸ ') : '';
|
|
243
357
|
const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : '';
|
|
244
|
-
const titleStyle = collapsible
|
|
245
|
-
? 'cursor:pointer;color:#f9a8d4'
|
|
246
|
-
: 'color:#f9a8d4';
|
|
358
|
+
const titleStyle = collapsible ? 'cursor:pointer;color:#f9a8d4' : 'color:#f9a8d4';
|
|
247
359
|
const titleAttr = collapsible ? ` data-q="${key}"` : '';
|
|
248
360
|
const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
|
|
249
361
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
body =
|
|
253
|
-
`<ol style="margin:2px 0 6px 0;padding-left:20px">` +
|
|
254
|
-
q.cards.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join('') +
|
|
255
|
-
`</ol>`;
|
|
256
|
-
} else if (!q.cards.length) {
|
|
257
|
-
body = `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
|
|
258
|
-
} else {
|
|
259
|
-
body = `<div style="margin:1px 0 6px 6px;opacity:.55">(${q.length} cards — click to expand)</div>`;
|
|
362
|
+
if (!q.cards.length) {
|
|
363
|
+
return title + `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
|
|
260
364
|
}
|
|
365
|
+
|
|
366
|
+
// Always list up to INLINE_THRESHOLD cards; the remainder hides behind an
|
|
367
|
+
// expand toggle so long queues never blow out the overlay but stay inspectable.
|
|
368
|
+
const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
|
|
369
|
+
const hiddenCount = q.length - shown.length;
|
|
370
|
+
const listMarginBottom = collapsible ? 2 : 6;
|
|
371
|
+
|
|
372
|
+
let body =
|
|
373
|
+
`<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` +
|
|
374
|
+
shown.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join('') +
|
|
375
|
+
`</ol>`;
|
|
376
|
+
|
|
377
|
+
if (collapsible) {
|
|
378
|
+
const footer = isOpen ? '▾ show less' : `… +${hiddenCount} more`;
|
|
379
|
+
body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
|
|
380
|
+
}
|
|
381
|
+
|
|
261
382
|
return title + body;
|
|
262
383
|
}
|
|
263
384
|
|
|
385
|
+
/** Compact, colour-coded per-card glyph: ✓ correct, ✗ wrong, · info-only. */
|
|
386
|
+
function outcomeGlyph(correct: boolean | null): string {
|
|
387
|
+
if (correct === true) return `<span style="color:#86efac">✓</span>`;
|
|
388
|
+
if (correct === false) return `<span style="color:#fca5a5">✗</span>`;
|
|
389
|
+
return `<span style="opacity:.5">·</span>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Expandable list of every card the learner has interacted with this session.
|
|
394
|
+
* Mirrors `queueHtml`'s collapse behaviour but renders richer per-card detail
|
|
395
|
+
* (status, outcome, attempt count) since this is the audit trail, not a queue.
|
|
396
|
+
*/
|
|
397
|
+
function drawnHtml(key: string, drawn: SessionDrawnCardDebug[]): string {
|
|
398
|
+
const collapsible = drawn.length > INLINE_THRESHOLD;
|
|
399
|
+
const isOpen = collapsible && expanded[key];
|
|
400
|
+
const caret = collapsible ? (expanded[key] ? '▾ ' : '▸ ') : '';
|
|
401
|
+
const titleStyle = collapsible ? 'cursor:pointer;color:#c4b5fd' : 'color:#c4b5fd';
|
|
402
|
+
const titleAttr = collapsible ? ` data-q="${key}"` : '';
|
|
403
|
+
const title = `<div${titleAttr} style="${titleStyle}">${caret}drawn: ${drawn.length}</div>`;
|
|
404
|
+
|
|
405
|
+
if (!drawn.length) {
|
|
406
|
+
return title + `<div style="margin:1px 0 6px 6px;opacity:.5">none yet</div>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const shown = isOpen ? drawn : drawn.slice(0, INLINE_THRESHOLD);
|
|
410
|
+
const hiddenCount = drawn.length - shown.length;
|
|
411
|
+
const listMarginBottom = collapsible ? 2 : 6;
|
|
412
|
+
|
|
413
|
+
const rows = shown
|
|
414
|
+
.map((d) => {
|
|
415
|
+
const retries = d.attempts > 1 ? `<span style="opacity:.5"> ×${d.attempts}</span>` : '';
|
|
416
|
+
const time = `<span style="opacity:.45"> ${Math.round(d.timeSpentMs / 100) / 10}s</span>`;
|
|
417
|
+
return (
|
|
418
|
+
`<li style="white-space:nowrap">${outcomeGlyph(d.correct)} ${esc(d.cardID)}` +
|
|
419
|
+
`<span style="opacity:.5"> [${esc(d.status)}]</span>${retries}${time}</li>`
|
|
420
|
+
);
|
|
421
|
+
})
|
|
422
|
+
.join('');
|
|
423
|
+
|
|
424
|
+
let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">${rows}</ol>`;
|
|
425
|
+
|
|
426
|
+
if (collapsible) {
|
|
427
|
+
const footer = isOpen ? '▾ show less' : `… +${hiddenCount} more`;
|
|
428
|
+
body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return title + body;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Plain-text rendering of a snapshot for the clipboard. Mirrors the on-screen
|
|
436
|
+
* sections (without truncation) so a copied dump is a complete, paste-able
|
|
437
|
+
* picture of session state at the moment of the click.
|
|
438
|
+
*/
|
|
439
|
+
function snapshotToText(s: SessionDebugSnapshot | null): string {
|
|
440
|
+
if (!s) return 'SessionController — no active session.';
|
|
441
|
+
|
|
442
|
+
const lines: string[] = [];
|
|
443
|
+
lines.push('=== SessionController ===');
|
|
444
|
+
lines.push(`time ${formatTime(s.secondsRemaining)}`);
|
|
445
|
+
if (s.hasCardGuarantee) lines.push(`guarantee: ${s.minCardsGuarantee}`);
|
|
446
|
+
lines.push(`well-indicated left: ${s.wellIndicatedRemaining}`);
|
|
447
|
+
lines.push(`current: ${s.currentCard ?? '—'}`);
|
|
448
|
+
lines.push(
|
|
449
|
+
s.replanActive ? `replan: ACTIVE [${s.replanLabel ?? '(auto)'}]` : 'replan: idle'
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
lines.push('');
|
|
453
|
+
lines.push('sessionHints:');
|
|
454
|
+
const h = s.sessionHints;
|
|
455
|
+
const hintParts: string[] = [];
|
|
456
|
+
if (h) {
|
|
457
|
+
if (h.boostTags && Object.keys(h.boostTags).length)
|
|
458
|
+
hintParts.push(` boost: ${Object.entries(h.boostTags).map(([k, v]) => `${k}×${v}`).join(', ')}`);
|
|
459
|
+
if (h.boostCards && Object.keys(h.boostCards).length)
|
|
460
|
+
hintParts.push(` boostCards: ${Object.entries(h.boostCards).map(([k, v]) => `${k}×${v}`).join(', ')}`);
|
|
461
|
+
if (h.requireCards?.length) hintParts.push(` require: ${h.requireCards.join(', ')}`);
|
|
462
|
+
if (h.requireTags?.length) hintParts.push(` requireTags: ${h.requireTags.join(', ')}`);
|
|
463
|
+
if (h.excludeTags?.length) hintParts.push(` exclude: ${h.excludeTags.join(', ')}`);
|
|
464
|
+
if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(', ')}`);
|
|
465
|
+
}
|
|
466
|
+
lines.push(hintParts.length ? hintParts.join('\n') : ' none');
|
|
467
|
+
|
|
468
|
+
const queueText = (label: string, q: SessionQueueDebug) => {
|
|
469
|
+
lines.push('');
|
|
470
|
+
lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
|
|
471
|
+
q.cards.forEach((c, i) => lines.push(` ${i + 1}. ${c}`));
|
|
472
|
+
};
|
|
473
|
+
queueText('reviewQ', s.reviewQ);
|
|
474
|
+
queueText('newQ', s.newQ);
|
|
475
|
+
queueText('failedQ', s.failedQ);
|
|
476
|
+
|
|
477
|
+
lines.push('');
|
|
478
|
+
lines.push(`drawn: ${s.drawnCards.length}`);
|
|
479
|
+
s.drawnCards.forEach((d, i) => {
|
|
480
|
+
const mark = d.correct === true ? '✓' : d.correct === false ? '✗' : '·';
|
|
481
|
+
const time = `${Math.round(d.timeSpentMs / 100) / 10}s`;
|
|
482
|
+
lines.push(` ${i + 1}. ${mark} ${d.cardID} [${d.status}] ×${d.attempts} ${time}`);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return lines.join('\n');
|
|
486
|
+
}
|
|
487
|
+
|
|
264
488
|
function formatTime(totalSeconds: number): string {
|
|
265
489
|
const s = Math.max(0, Math.round(totalSeconds));
|
|
266
490
|
const m = Math.floor(s / 60);
|