@vue-skuilder/db 0.2.4 → 0.2.7
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 +37 -1
- package/dist/core/index.d.ts +37 -1
- package/dist/core/index.js +217 -8
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +214 -8
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +211 -8
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +211 -8
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +211 -8
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +211 -8
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +59 -1
- package/dist/index.d.ts +59 -1
- package/dist/index.js +444 -25
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +441 -25
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +42 -2
- package/package.json +3 -3
- package/src/core/navigators/Pipeline.ts +10 -1
- package/src/core/navigators/diversityRerank.ts +185 -0
- package/src/core/navigators/generators/elo.ts +32 -11
- package/src/core/navigators/generators/prescribed.ts +173 -1
- package/src/core/navigators/index.ts +8 -0
- package/src/study/ItemQueue.test.ts +71 -0
- package/src/study/ItemQueue.ts +19 -1
- package/src/study/SessionController.ts +123 -7
- package/src/study/SessionOverlay.ts +245 -21
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ItemQueue } from './ItemQueue';
|
|
3
|
+
|
|
4
|
+
type Item = { cardID: string };
|
|
5
|
+
const id = (i: Item) => i.cardID;
|
|
6
|
+
const item = (cardID: string): Item => ({ cardID });
|
|
7
|
+
const ids = (q: ItemQueue<Item>): string[] =>
|
|
8
|
+
Array.from({ length: q.length }, (_, i) => q.peek(i).cardID);
|
|
9
|
+
|
|
10
|
+
describe('ItemQueue.mergeToFront', () => {
|
|
11
|
+
it('adds new items to the front, preserving batch order', () => {
|
|
12
|
+
const q = new ItemQueue<Item>();
|
|
13
|
+
q.addAll([item('a'), item('b')], id);
|
|
14
|
+
|
|
15
|
+
const added = q.mergeToFront([item('x'), item('y')], id);
|
|
16
|
+
|
|
17
|
+
expect(added).toBe(2);
|
|
18
|
+
expect(ids(q)).toEqual(['x', 'y', 'a', 'b']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('skips an ordinary duplicate, leaving it in place', () => {
|
|
22
|
+
const q = new ItemQueue<Item>();
|
|
23
|
+
q.addAll([item('a'), item('b'), item('c')], id);
|
|
24
|
+
|
|
25
|
+
// 'b' already queued and not mandatory → left where it is; 'x' fronted.
|
|
26
|
+
const added = q.mergeToFront([item('x'), item('b')], id);
|
|
27
|
+
|
|
28
|
+
expect(added).toBe(1);
|
|
29
|
+
expect(ids(q)).toEqual(['x', 'a', 'b', 'c']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('re-fronts an already-queued mandatory card instead of burying it', () => {
|
|
33
|
+
// Repro of the require-card burial: 'req' was fronted by a prior burst
|
|
34
|
+
// replan, then an additive merge brings fresh non-required cards. Without
|
|
35
|
+
// the mandatory re-front, 'x'/'y' would leapfrog 'req' and sink it.
|
|
36
|
+
const q = new ItemQueue<Item>();
|
|
37
|
+
q.addAll([item('req'), item('a'), item('b')], id);
|
|
38
|
+
|
|
39
|
+
const added = q.mergeToFront(
|
|
40
|
+
[item('req'), item('x'), item('y')],
|
|
41
|
+
id,
|
|
42
|
+
new Set(['req'])
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// 'req' is not a *new* add, so it isn't counted...
|
|
46
|
+
expect(added).toBe(2);
|
|
47
|
+
// ...but it leads the queue (ahead of the freshly merged 'x'/'y').
|
|
48
|
+
expect(ids(q)).toEqual(['req', 'x', 'y', 'a', 'b']);
|
|
49
|
+
// and isn't duplicated.
|
|
50
|
+
expect(ids(q).filter((c) => c === 'req')).toHaveLength(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps a mandatory card already at the front at the front', () => {
|
|
54
|
+
const q = new ItemQueue<Item>();
|
|
55
|
+
q.addAll([item('req'), item('a')], id);
|
|
56
|
+
|
|
57
|
+
q.mergeToFront([item('req'), item('x')], id, new Set(['req']));
|
|
58
|
+
|
|
59
|
+
expect(ids(q)).toEqual(['req', 'x', 'a']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('without forceFrontIds, preserves the legacy skip-duplicate behavior', () => {
|
|
63
|
+
const q = new ItemQueue<Item>();
|
|
64
|
+
q.addAll([item('req'), item('a')], id);
|
|
65
|
+
|
|
66
|
+
// No mandatory set → 'req' stays put and is buried behind the merged 'x'.
|
|
67
|
+
q.mergeToFront([item('req'), item('x')], id);
|
|
68
|
+
|
|
69
|
+
expect(ids(q)).toEqual(['x', 'req', 'a']);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/study/ItemQueue.ts
CHANGED
|
@@ -73,8 +73,21 @@ export class ItemQueue<T> {
|
|
|
73
73
|
* Merge new items into the front of the queue, skipping duplicates.
|
|
74
74
|
* Used by additive replans to inject high-quality candidates without
|
|
75
75
|
* discarding the existing queue contents.
|
|
76
|
+
*
|
|
77
|
+
* `forceFrontIds` carries the mandatory (`+INF`) cards in this batch — a
|
|
78
|
+
* durable `requireCard`/`requireTag` re-asserted by every replan. An ordinary
|
|
79
|
+
* duplicate is left in place (skip), but a mandatory one that's *already*
|
|
80
|
+
* queued is pulled out of its current slot so it rejoins at the front in batch
|
|
81
|
+
* order. Without this, an additive merge unshifts fresh non-required cards
|
|
82
|
+
* ahead of an already-present required card, steadily burying it until it never
|
|
83
|
+
* gets drawn — defeating the "must appear" guarantee. Returns the count of
|
|
84
|
+
* genuinely new cards added (re-fronted duplicates are not counted).
|
|
76
85
|
*/
|
|
77
|
-
public mergeToFront(
|
|
86
|
+
public mergeToFront(
|
|
87
|
+
items: T[],
|
|
88
|
+
cardIdExtractor: (item: T) => string,
|
|
89
|
+
forceFrontIds?: ReadonlySet<string>
|
|
90
|
+
): number {
|
|
78
91
|
let added = 0;
|
|
79
92
|
const toInsert: T[] = [];
|
|
80
93
|
for (const item of items) {
|
|
@@ -83,6 +96,11 @@ export class ItemQueue<T> {
|
|
|
83
96
|
this.seenCardIds.push(cardId);
|
|
84
97
|
toInsert.push(item);
|
|
85
98
|
added++;
|
|
99
|
+
} else if (forceFrontIds?.has(cardId)) {
|
|
100
|
+
const idx = this.q.findIndex((qi) => cardIdExtractor(qi) === cardId);
|
|
101
|
+
if (idx >= 0) {
|
|
102
|
+
toInsert.push(...this.q.splice(idx, 1));
|
|
103
|
+
}
|
|
86
104
|
}
|
|
87
105
|
}
|
|
88
106
|
this.q.unshift(...toInsert);
|
|
@@ -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,9 +1156,27 @@ 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);
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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.
|
|
1164
|
+
const newCandidates = mixedWeighted.filter(
|
|
1165
|
+
(w) => getCardOrigin(w) === 'new' && !this._servedCardIds.has(w.cardId)
|
|
1166
|
+
);
|
|
1167
|
+
// `+INF` is the hard "include at all costs" sentinel applied by require*
|
|
1168
|
+
// injection (see Pipeline.applyRequirement). Partition these mandatory cards
|
|
1169
|
+
// to the front and exempt them from the newLimit slice, so neither the
|
|
1170
|
+
// mixer's source-shuffle/round-robin nor the cap can bury or drop a required
|
|
1171
|
+
// card before it reaches newQ. The set is also handed to mergeToFront so an
|
|
1172
|
+
// already-queued required card gets re-fronted rather than leapfrogged.
|
|
1173
|
+
const mandatoryWeighted = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
|
|
1174
|
+
const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
|
|
1175
|
+
const newWeighted = [
|
|
1176
|
+
...mandatoryWeighted,
|
|
1177
|
+
...optionalWeighted.slice(0, Math.max(0, newLimit - mandatoryWeighted.length)),
|
|
1178
|
+
];
|
|
1179
|
+
const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
|
|
1106
1180
|
|
|
1107
1181
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
1108
1182
|
|
|
@@ -1145,8 +1219,10 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1145
1219
|
}
|
|
1146
1220
|
|
|
1147
1221
|
if (additive) {
|
|
1148
|
-
// Additive replan: merge new candidates into front of existing queue
|
|
1149
|
-
|
|
1222
|
+
// Additive replan: merge new candidates into front of existing queue.
|
|
1223
|
+
// Pass mandatory (+INF) ids so an already-queued required card is pulled
|
|
1224
|
+
// back to the front instead of being buried by fresh non-required cards.
|
|
1225
|
+
const added = this.newQ.mergeToFront(newItems, (item) => item.cardID, mandatoryIds);
|
|
1150
1226
|
report += `Additive merge: ${added} new cards added to front of newQ\n`;
|
|
1151
1227
|
} else if (replan) {
|
|
1152
1228
|
// Atomic swap: replace entire newQ contents at once (no empty-queue window)
|
|
@@ -1347,7 +1423,10 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1347
1423
|
`Triggering background replan.`
|
|
1348
1424
|
);
|
|
1349
1425
|
// label is observability-only (overlay/logs); does not affect coalescing.
|
|
1350
|
-
|
|
1426
|
+
// mode:'merge' preserves any already-queued cards (e.g. an undrawn
|
|
1427
|
+
// prescribed WST whose one-shot requireCards won't be re-asserted by this
|
|
1428
|
+
// bare replan) instead of replaceAll() wiping them. See mergeToFront.
|
|
1429
|
+
void this.requestReplan({ label: 'auto:depletion', mode: 'merge' });
|
|
1351
1430
|
}
|
|
1352
1431
|
|
|
1353
1432
|
// Opportunistic quality: few well-indicated cards remain.
|
|
@@ -1586,6 +1665,21 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1586
1665
|
* Remove an item from its source queue after consumption by nextCard().
|
|
1587
1666
|
*/
|
|
1588
1667
|
private removeItemFromQueue(item: StudySessionItem): void {
|
|
1668
|
+
// Durable-until-drawn requirements: a caller may place a card ID in the
|
|
1669
|
+
// session-durable `requireCards` (via mergeSessionHints) so every later
|
|
1670
|
+
// replan re-asserts it at +INF until it actually surfaces — e.g. a
|
|
1671
|
+
// prescribed intro follow-up that must survive the replace-mode burst/auto
|
|
1672
|
+
// replans that would otherwise clobber a one-shot requirement. The
|
|
1673
|
+
// requirement is satisfied the instant the card is drawn, so clear it here
|
|
1674
|
+
// (the single consumption choke-point) to stop it being re-injected forever
|
|
1675
|
+
// and to bound accumulation. Generic: card-ID only, no domain vocabulary.
|
|
1676
|
+
this._clearDurableRequirement(item.cardID);
|
|
1677
|
+
|
|
1678
|
+
// Record the draw immediately (earlier than _sessionRecord, which waits for
|
|
1679
|
+
// a response) so getWeightedContent can keep this card out of newQ on any
|
|
1680
|
+
// replan that lands after this draw.
|
|
1681
|
+
this._servedCardIds.add(item.cardID);
|
|
1682
|
+
|
|
1589
1683
|
// Check each queue - item should be at the front of one of them
|
|
1590
1684
|
if (this.reviewQ.peek(0)?.cardID === item.cardID) {
|
|
1591
1685
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
@@ -1599,6 +1693,28 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1599
1693
|
}
|
|
1600
1694
|
}
|
|
1601
1695
|
|
|
1696
|
+
/**
|
|
1697
|
+
* Remove a satisfied card ID from the durable session-hint `requireCards`
|
|
1698
|
+
* list. Called when a card is consumed (see removeItemFromQueue). No-op if
|
|
1699
|
+
* the card was not a durable requirement.
|
|
1700
|
+
*
|
|
1701
|
+
* Matches literal IDs only: a glob/pattern requirement (which may stand for
|
|
1702
|
+
* several cards) is NOT considered satisfied by a single draw and is left in
|
|
1703
|
+
* place — durable patterns are the caller's responsibility, one-shot `hints`
|
|
1704
|
+
* remain the right tool for them.
|
|
1705
|
+
*/
|
|
1706
|
+
private _clearDurableRequirement(cardID: string): void {
|
|
1707
|
+
const req = this._sessionHints?.requireCards;
|
|
1708
|
+
if (!req || req.length === 0) return;
|
|
1709
|
+
const next = req.filter((id) => id !== cardID);
|
|
1710
|
+
if (next.length === req.length) return; // not a durable requirement
|
|
1711
|
+
this._sessionHints = {
|
|
1712
|
+
...this._sessionHints!,
|
|
1713
|
+
requireCards: next.length > 0 ? next : undefined,
|
|
1714
|
+
};
|
|
1715
|
+
this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1602
1718
|
/**
|
|
1603
1719
|
* End the session and record learning outcomes.
|
|
1604
1720
|
*
|
|
@@ -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);
|