@vue-skuilder/db 0.1.23 → 0.1.25
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-BP9hznNV.d.ts → contentSource-BmnmvH8C.d.ts} +268 -3
- package/dist/{contentSource-DsJadoBU.d.cts → contentSource-DfBbaLA-.d.cts} +268 -3
- package/dist/core/index.d.cts +310 -6
- package/dist/core/index.d.ts +310 -6
- package/dist/core/index.js +2606 -666
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2564 -639
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
- package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +11 -3
- package/dist/impl/couch/index.d.ts +11 -3
- package/dist/impl/couch/index.js +2336 -656
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2316 -631
- 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 +2312 -632
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +2315 -630
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Dj0SEgk3.d.ts → index-BWvO-_rJ.d.ts} +1 -1
- package/dist/{index-B_j6u5E4.d.cts → index-Ba7hYbHj.d.cts} +1 -1
- package/dist/index.d.cts +278 -20
- package/dist/index.d.ts +278 -20
- package/dist/index.js +3603 -720
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3529 -674
- package/dist/index.mjs.map +1 -1
- package/dist/{types-DQaXnuoc.d.ts → types-CJrLM1Ew.d.ts} +1 -1
- package/dist/{types-Bn0itutr.d.cts → types-W8n-B6HG.d.cts} +1 -1
- package/dist/{types-legacy-DDY4N-Uq.d.cts → types-legacy-JXDxinpU.d.cts} +5 -1
- package/dist/{types-legacy-DDY4N-Uq.d.ts → types-legacy-JXDxinpU.d.ts} +5 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/brainstorm-navigation-paradigm.md +40 -34
- package/docs/future-orchestration-vision.md +216 -0
- package/docs/navigators-architecture.md +210 -9
- package/docs/todo-review-urgency-adaptation.md +205 -0
- package/docs/todo-strategy-authoring.md +8 -6
- package/package.json +3 -3
- package/src/core/index.ts +2 -0
- package/src/core/interfaces/contentSource.ts +7 -0
- package/src/core/interfaces/userDB.ts +50 -0
- package/src/core/navigators/Pipeline.ts +132 -5
- package/src/core/navigators/PipelineAssembler.ts +21 -22
- package/src/core/navigators/PipelineDebugger.ts +426 -0
- package/src/core/navigators/filters/WeightedFilter.ts +141 -0
- package/src/core/navigators/filters/types.ts +4 -0
- package/src/core/navigators/generators/CompositeGenerator.ts +82 -19
- package/src/core/navigators/generators/elo.ts +14 -1
- package/src/core/navigators/generators/srs.ts +146 -18
- package/src/core/navigators/generators/types.ts +4 -0
- package/src/core/navigators/index.ts +203 -13
- package/src/core/orchestration/gradient.ts +133 -0
- package/src/core/orchestration/index.ts +210 -0
- package/src/core/orchestration/learning.ts +250 -0
- package/src/core/orchestration/recording.ts +92 -0
- package/src/core/orchestration/signal.ts +67 -0
- package/src/core/types/contentNavigationStrategy.ts +38 -0
- package/src/core/types/learningState.ts +77 -0
- package/src/core/types/types-legacy.ts +4 -0
- package/src/core/types/userOutcome.ts +51 -0
- package/src/courseConfigRegistration.ts +107 -0
- package/src/factory.ts +6 -0
- package/src/impl/common/BaseUserDB.ts +16 -0
- package/src/impl/couch/user-course-relDB.ts +12 -0
- package/src/study/MixerDebugger.ts +555 -0
- package/src/study/SessionController.ts +159 -20
- package/src/study/SessionDebugger.ts +442 -0
- package/src/study/SourceMixer.ts +36 -17
- package/src/study/TODO-session-scheduling.md +133 -0
- package/src/study/index.ts +2 -0
- package/src/study/services/EloService.ts +79 -4
- package/src/study/services/ResponseProcessor.ts +130 -72
- package/src/study/services/SrsService.ts +9 -0
- package/tests/core/navigators/Pipeline.test.ts +2 -0
- package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
- package/docs/todo-evolutionary-orchestration.md +0 -310
|
@@ -12,10 +12,13 @@ import {
|
|
|
12
12
|
StudySessionReviewItem,
|
|
13
13
|
} from '@db/impl/couch';
|
|
14
14
|
|
|
15
|
-
import { CardRecord, CardHistory, CourseRegistrationDoc } from '@db/core';
|
|
15
|
+
import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord } from '@db/core';
|
|
16
|
+
import { recordUserOutcome } from '@db/core/orchestration/recording';
|
|
16
17
|
import { Loggable } from '@db/util';
|
|
17
18
|
import { getCardOrigin } from '@db/core/navigators';
|
|
18
19
|
import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
20
|
+
import { captureMixerRun } from './MixerDebugger';
|
|
21
|
+
import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
|
|
19
22
|
|
|
20
23
|
export interface StudySessionRecord {
|
|
21
24
|
card: {
|
|
@@ -61,6 +64,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
61
64
|
private eloService: EloService;
|
|
62
65
|
private hydrationService: CardHydrationService<TView>;
|
|
63
66
|
private mixer: SourceMixer;
|
|
67
|
+
private dataLayer: DataLayerProvider;
|
|
68
|
+
private courseNameCache: Map<string, string> = new Map();
|
|
64
69
|
|
|
65
70
|
private sources: StudyContentSource[];
|
|
66
71
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
@@ -84,7 +89,11 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
84
89
|
return this._secondsRemaining;
|
|
85
90
|
}
|
|
86
91
|
public get report(): string {
|
|
87
|
-
|
|
92
|
+
const reviewCount = this.reviewQ.dequeueCount;
|
|
93
|
+
const newCount = this.newQ.dequeueCount;
|
|
94
|
+
const reviewWord = reviewCount === 1 ? 'review' : 'reviews';
|
|
95
|
+
const newCardWord = newCount === 1 ? 'new card' : 'new cards';
|
|
96
|
+
return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
|
|
88
97
|
}
|
|
89
98
|
public get detailedReport(): string {
|
|
90
99
|
return this.newQ.toString + '\n' + this.reviewQ.toString + '\n' + this.failedQ.toString;
|
|
@@ -108,6 +117,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
108
117
|
) {
|
|
109
118
|
super();
|
|
110
119
|
|
|
120
|
+
this.dataLayer = dataLayer;
|
|
111
121
|
this.mixer = mixer || new QuotaRoundRobinMixer();
|
|
112
122
|
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
113
123
|
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
@@ -193,6 +203,9 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
193
203
|
await this.getWeightedContent();
|
|
194
204
|
await this.hydrationService.ensureHydratedCards();
|
|
195
205
|
|
|
206
|
+
// Start session tracking for debugging
|
|
207
|
+
startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
208
|
+
|
|
196
209
|
this._intervalHandle = setInterval(() => {
|
|
197
210
|
this.tick();
|
|
198
211
|
}, 1000);
|
|
@@ -310,6 +323,35 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
310
323
|
// Mix weighted cards across sources using configured strategy
|
|
311
324
|
const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
|
|
312
325
|
|
|
326
|
+
// Capture mixer run for debugging - fetch course names
|
|
327
|
+
const sourceIds = batches.map((b) => {
|
|
328
|
+
const firstCard = b.weighted[0];
|
|
329
|
+
return firstCard?.courseId || `source-${b.sourceIndex}`;
|
|
330
|
+
});
|
|
331
|
+
// Populate course name cache (one-time fetch, reused by SessionDebugger)
|
|
332
|
+
await Promise.all(
|
|
333
|
+
sourceIds.map(async (id) => {
|
|
334
|
+
try {
|
|
335
|
+
const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
|
|
336
|
+
this.courseNameCache.set(id, config.name);
|
|
337
|
+
} catch {
|
|
338
|
+
// leave unmapped
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
const sourceNames = sourceIds.map((id) => this.courseNameCache.get(id));
|
|
343
|
+
const quotaPerSource =
|
|
344
|
+
this.mixer instanceof QuotaRoundRobinMixer ? Math.ceil((limit * this.sources.length) / batches.length) : undefined;
|
|
345
|
+
captureMixerRun(
|
|
346
|
+
this.mixer.constructor.name,
|
|
347
|
+
batches,
|
|
348
|
+
sourceIds,
|
|
349
|
+
sourceNames,
|
|
350
|
+
limit * this.sources.length,
|
|
351
|
+
quotaPerSource,
|
|
352
|
+
mixedWeighted
|
|
353
|
+
);
|
|
354
|
+
|
|
313
355
|
// Split mixed results by card origin
|
|
314
356
|
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'review');
|
|
315
357
|
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'new');
|
|
@@ -453,32 +495,67 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
453
495
|
|
|
454
496
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
455
497
|
this._currentCard = null;
|
|
498
|
+
endSessionTracking();
|
|
456
499
|
return null;
|
|
457
500
|
}
|
|
458
501
|
|
|
459
|
-
//
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
502
|
+
// Try multiple cards in case some fail hydration (e.g., deleted from DB)
|
|
503
|
+
const MAX_SKIP = 20;
|
|
504
|
+
for (let attempt = 0; attempt < MAX_SKIP; attempt++) {
|
|
505
|
+
const nextItem = this._selectNextItemToHydrate();
|
|
506
|
+
if (!nextItem) {
|
|
507
|
+
this._currentCard = null;
|
|
508
|
+
endSessionTracking();
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
465
511
|
|
|
466
|
-
|
|
467
|
-
|
|
512
|
+
// Look up in hydration cache
|
|
513
|
+
let card = this.hydrationService.getHydratedCard(nextItem.cardID);
|
|
468
514
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
515
|
+
// If not ready, wait for it
|
|
516
|
+
if (!card) {
|
|
517
|
+
card = await this.hydrationService.waitForCard(nextItem.cardID);
|
|
518
|
+
}
|
|
473
519
|
|
|
474
|
-
|
|
475
|
-
|
|
520
|
+
// Remove from source queue now that we're consuming it
|
|
521
|
+
this.removeItemFromQueue(nextItem);
|
|
476
522
|
|
|
477
|
-
|
|
478
|
-
|
|
523
|
+
if (card) {
|
|
524
|
+
// Trigger background hydration to maintain cache (async, non-blocking)
|
|
525
|
+
await this.hydrationService.ensureHydratedCards();
|
|
526
|
+
this._currentCard = card;
|
|
527
|
+
|
|
528
|
+
// Record presentation for debugging
|
|
529
|
+
const origin = nextItem.status === 'review' || nextItem.status === 'failed-review' ? 'review' :
|
|
530
|
+
nextItem.status === 'new' || nextItem.status === 'failed-new' ? 'new' : 'failed';
|
|
531
|
+
const queueSource = nextItem.status.startsWith('failed') ? 'failedQ' :
|
|
532
|
+
(nextItem.status === 'review' ? 'reviewQ' : 'newQ');
|
|
533
|
+
|
|
534
|
+
recordCardPresentation(
|
|
535
|
+
nextItem.cardID,
|
|
536
|
+
nextItem.courseID,
|
|
537
|
+
this.courseNameCache.get(nextItem.courseID),
|
|
538
|
+
origin,
|
|
539
|
+
queueSource as 'reviewQ' | 'newQ' | 'failedQ'
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
// Snapshot queue state
|
|
543
|
+
snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
479
544
|
|
|
480
|
-
|
|
481
|
-
|
|
545
|
+
return card;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Card failed hydration (deleted from DB?) — skip and clean up
|
|
549
|
+
this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
|
|
550
|
+
if (isReview(nextItem)) {
|
|
551
|
+
this.srsService.removeReview(nextItem.reviewID);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
this.log(`Exhausted ${MAX_SKIP} skip attempts finding a hydratable card`);
|
|
556
|
+
this._currentCard = null;
|
|
557
|
+
endSessionTracking();
|
|
558
|
+
return null;
|
|
482
559
|
}
|
|
483
560
|
|
|
484
561
|
/**
|
|
@@ -587,4 +664,66 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
587
664
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
588
665
|
}
|
|
589
666
|
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* End the session and record learning outcomes.
|
|
670
|
+
*
|
|
671
|
+
* This method aggregates all responses from the session and records a
|
|
672
|
+
* UserOutcomeRecord if evolutionary orchestration is enabled.
|
|
673
|
+
*/
|
|
674
|
+
public async endSession(): Promise<void> {
|
|
675
|
+
if (!this._sessionRecord || this._sessionRecord.length === 0) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const questionRecords = this._sessionRecord
|
|
680
|
+
.flatMap((r) => r.records)
|
|
681
|
+
.filter((r): r is QuestionRecord => (r as any).userAnswer !== undefined);
|
|
682
|
+
|
|
683
|
+
if (questionRecords.length === 0) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// We need to access the orchestration context.
|
|
688
|
+
// Ideally this would be passed in or available via services.
|
|
689
|
+
// For now, we'll try to get it from one of the content sources if possible,
|
|
690
|
+
// or skip if we can't access it.
|
|
691
|
+
|
|
692
|
+
// Try to find a source that supports orchestration
|
|
693
|
+
let orchestrationContext = null;
|
|
694
|
+
const strategies: string[] = [];
|
|
695
|
+
|
|
696
|
+
for (const source of this.sources) {
|
|
697
|
+
if (source.getOrchestrationContext) {
|
|
698
|
+
try {
|
|
699
|
+
orchestrationContext = await source.getOrchestrationContext();
|
|
700
|
+
// Also try to get strategy IDs if available on the source (e.g. Pipeline)
|
|
701
|
+
if ((source as any).getStrategyIds) {
|
|
702
|
+
strategies.push(...(source as any).getStrategyIds());
|
|
703
|
+
}
|
|
704
|
+
} catch (e) {
|
|
705
|
+
logger.warn(`[SessionController] Failed to get orchestration context: ${e}`);
|
|
706
|
+
}
|
|
707
|
+
if (orchestrationContext) break;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (!orchestrationContext) {
|
|
712
|
+
logger.debug('[SessionController] No orchestration context available, skipping outcome recording');
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Use current time as period end
|
|
717
|
+
const periodEnd = new Date().toISOString();
|
|
718
|
+
// Use session start time as period start
|
|
719
|
+
const periodStart = new Date(this.startTime).toISOString();
|
|
720
|
+
|
|
721
|
+
await recordUserOutcome(
|
|
722
|
+
orchestrationContext,
|
|
723
|
+
periodStart,
|
|
724
|
+
periodEnd,
|
|
725
|
+
questionRecords,
|
|
726
|
+
strategies
|
|
727
|
+
);
|
|
728
|
+
}
|
|
590
729
|
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { logger } from '../util/logger';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// SESSION DEBUGGER
|
|
5
|
+
// ============================================================================
|
|
6
|
+
//
|
|
7
|
+
// Console-accessible debug API for inspecting session runtime behavior.
|
|
8
|
+
//
|
|
9
|
+
// Exposed as `window.skuilder.session` for interactive exploration.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// window.skuilder.session.showQueue()
|
|
13
|
+
// window.skuilder.session.showHistory()
|
|
14
|
+
// window.skuilder.session.showInterleaving()
|
|
15
|
+
// window.skuilder.session.export()
|
|
16
|
+
//
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Snapshot of queue state at a given moment.
|
|
21
|
+
*/
|
|
22
|
+
export interface QueueSnapshot {
|
|
23
|
+
timestamp: Date;
|
|
24
|
+
reviewQLength: number;
|
|
25
|
+
newQLength: number;
|
|
26
|
+
failedQLength: number;
|
|
27
|
+
reviewQNext3?: string[]; // cardIds of next 3 in reviewQ
|
|
28
|
+
newQNext3?: string[]; // cardIds of next 3 in newQ
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record of a single card presentation.
|
|
33
|
+
*/
|
|
34
|
+
export interface CardPresentation {
|
|
35
|
+
timestamp: Date;
|
|
36
|
+
sequenceNumber: number; // 1-indexed position in session
|
|
37
|
+
cardId: string;
|
|
38
|
+
courseId: string;
|
|
39
|
+
courseName?: string;
|
|
40
|
+
origin: 'review' | 'new' | 'failed';
|
|
41
|
+
queueSource: 'reviewQ' | 'newQ' | 'failedQ';
|
|
42
|
+
score?: number; // If available from weighted cards
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Complete session execution record.
|
|
47
|
+
*/
|
|
48
|
+
export interface SessionRunReport {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
startTime: Date;
|
|
51
|
+
endTime?: Date;
|
|
52
|
+
|
|
53
|
+
// Initial state
|
|
54
|
+
initialQueues: QueueSnapshot;
|
|
55
|
+
|
|
56
|
+
// Card presentations in order
|
|
57
|
+
presentations: CardPresentation[];
|
|
58
|
+
|
|
59
|
+
// Queue snapshots at various points
|
|
60
|
+
queueSnapshots: QueueSnapshot[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Active session state.
|
|
65
|
+
*/
|
|
66
|
+
let activeSession: SessionRunReport | null = null;
|
|
67
|
+
const sessionHistory: SessionRunReport[] = [];
|
|
68
|
+
const MAX_HISTORY = 5;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Start tracking a new session.
|
|
72
|
+
*/
|
|
73
|
+
export function startSessionTracking(
|
|
74
|
+
reviewQLength: number,
|
|
75
|
+
newQLength: number,
|
|
76
|
+
failedQLength: number
|
|
77
|
+
): void {
|
|
78
|
+
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
79
|
+
|
|
80
|
+
activeSession = {
|
|
81
|
+
sessionId,
|
|
82
|
+
startTime: new Date(),
|
|
83
|
+
initialQueues: {
|
|
84
|
+
timestamp: new Date(),
|
|
85
|
+
reviewQLength,
|
|
86
|
+
newQLength,
|
|
87
|
+
failedQLength,
|
|
88
|
+
},
|
|
89
|
+
presentations: [],
|
|
90
|
+
queueSnapshots: [],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
logger.debug(`[SessionDebugger] Started tracking session: ${sessionId}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Record a card presentation.
|
|
98
|
+
*/
|
|
99
|
+
export function recordCardPresentation(
|
|
100
|
+
cardId: string,
|
|
101
|
+
courseId: string,
|
|
102
|
+
courseName: string | undefined,
|
|
103
|
+
origin: 'review' | 'new' | 'failed',
|
|
104
|
+
queueSource: 'reviewQ' | 'newQ' | 'failedQ',
|
|
105
|
+
score?: number
|
|
106
|
+
): void {
|
|
107
|
+
if (!activeSession) {
|
|
108
|
+
logger.warn('[SessionDebugger] No active session to record presentation');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
activeSession.presentations.push({
|
|
113
|
+
timestamp: new Date(),
|
|
114
|
+
sequenceNumber: activeSession.presentations.length + 1,
|
|
115
|
+
cardId,
|
|
116
|
+
courseId,
|
|
117
|
+
courseName,
|
|
118
|
+
origin,
|
|
119
|
+
queueSource,
|
|
120
|
+
score,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Take a snapshot of current queue state.
|
|
126
|
+
*/
|
|
127
|
+
export function snapshotQueues(
|
|
128
|
+
reviewQLength: number,
|
|
129
|
+
newQLength: number,
|
|
130
|
+
failedQLength: number,
|
|
131
|
+
reviewQNext3?: string[],
|
|
132
|
+
newQNext3?: string[]
|
|
133
|
+
): void {
|
|
134
|
+
if (!activeSession) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
activeSession.queueSnapshots.push({
|
|
139
|
+
timestamp: new Date(),
|
|
140
|
+
reviewQLength,
|
|
141
|
+
newQLength,
|
|
142
|
+
failedQLength,
|
|
143
|
+
reviewQNext3,
|
|
144
|
+
newQNext3,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* End the current session tracking.
|
|
150
|
+
*/
|
|
151
|
+
export function endSessionTracking(): void {
|
|
152
|
+
if (!activeSession) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
activeSession.endTime = new Date();
|
|
157
|
+
|
|
158
|
+
// Add to history
|
|
159
|
+
sessionHistory.unshift(activeSession);
|
|
160
|
+
if (sessionHistory.length > MAX_HISTORY) {
|
|
161
|
+
sessionHistory.pop();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
logger.debug(`[SessionDebugger] Ended session: ${activeSession.sessionId}`);
|
|
165
|
+
activeSession = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// CONSOLE API
|
|
170
|
+
// ============================================================================
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Show current queue state (if session active).
|
|
174
|
+
*/
|
|
175
|
+
function showCurrentQueue(): void {
|
|
176
|
+
if (!activeSession) {
|
|
177
|
+
logger.info('[Session Debug] No active session.');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
|
|
182
|
+
|
|
183
|
+
// eslint-disable-next-line no-console
|
|
184
|
+
console.group('📊 Current Queue State');
|
|
185
|
+
logger.info(`Review Queue: ${latest.reviewQLength} cards`);
|
|
186
|
+
if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
|
|
187
|
+
logger.info(` Next: ${latest.reviewQNext3.join(', ')}`);
|
|
188
|
+
}
|
|
189
|
+
logger.info(`New Queue: ${latest.newQLength} cards`);
|
|
190
|
+
if (latest.newQNext3 && latest.newQNext3.length > 0) {
|
|
191
|
+
logger.info(` Next: ${latest.newQNext3.join(', ')}`);
|
|
192
|
+
}
|
|
193
|
+
logger.info(`Failed Queue: ${latest.failedQLength} cards`);
|
|
194
|
+
// eslint-disable-next-line no-console
|
|
195
|
+
console.groupEnd();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Show presentation history for current or last session.
|
|
200
|
+
*/
|
|
201
|
+
function showPresentationHistory(sessionIndex: number = 0): void {
|
|
202
|
+
const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
|
|
203
|
+
|
|
204
|
+
if (!session) {
|
|
205
|
+
logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// eslint-disable-next-line no-console
|
|
210
|
+
console.group(`📜 Session History: ${session.sessionId}`);
|
|
211
|
+
logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
|
|
212
|
+
if (session.endTime) {
|
|
213
|
+
logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
|
|
214
|
+
}
|
|
215
|
+
logger.info(`Cards presented: ${session.presentations.length}`);
|
|
216
|
+
|
|
217
|
+
if (session.presentations.length > 0) {
|
|
218
|
+
// eslint-disable-next-line no-console
|
|
219
|
+
console.table(
|
|
220
|
+
session.presentations.map((p) => ({
|
|
221
|
+
'#': p.sequenceNumber,
|
|
222
|
+
course: p.courseName || p.courseId.slice(0, 8),
|
|
223
|
+
origin: p.origin,
|
|
224
|
+
queue: p.queueSource,
|
|
225
|
+
score: p.score?.toFixed(3) || '-',
|
|
226
|
+
time: p.timestamp.toLocaleTimeString(),
|
|
227
|
+
}))
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// eslint-disable-next-line no-console
|
|
232
|
+
console.groupEnd();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Analyze course interleaving pattern.
|
|
237
|
+
*/
|
|
238
|
+
function showInterleaving(sessionIndex: number = 0): void {
|
|
239
|
+
const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
|
|
240
|
+
|
|
241
|
+
if (!session) {
|
|
242
|
+
logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// eslint-disable-next-line no-console
|
|
247
|
+
console.group('🔀 Interleaving Analysis');
|
|
248
|
+
|
|
249
|
+
// Course distribution
|
|
250
|
+
const courseCounts = new Map<string, number>();
|
|
251
|
+
const courseOrigins = new Map<string, { review: number; new: number; failed: number }>();
|
|
252
|
+
|
|
253
|
+
session.presentations.forEach((p) => {
|
|
254
|
+
const name = p.courseName || p.courseId;
|
|
255
|
+
courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
|
|
256
|
+
|
|
257
|
+
if (!courseOrigins.has(name)) {
|
|
258
|
+
courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
|
|
259
|
+
}
|
|
260
|
+
const origins = courseOrigins.get(name)!;
|
|
261
|
+
origins[p.origin]++;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
logger.info('Course distribution:');
|
|
265
|
+
// eslint-disable-next-line no-console
|
|
266
|
+
console.table(
|
|
267
|
+
Array.from(courseCounts.entries()).map(([course, count]) => {
|
|
268
|
+
const origins = courseOrigins.get(course)!;
|
|
269
|
+
return {
|
|
270
|
+
course,
|
|
271
|
+
total: count,
|
|
272
|
+
reviews: origins.review,
|
|
273
|
+
new: origins.new,
|
|
274
|
+
failed: origins.failed,
|
|
275
|
+
percentage: ((count / session.presentations.length) * 100).toFixed(1) + '%',
|
|
276
|
+
};
|
|
277
|
+
})
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Show interleaving pattern (first 20 cards)
|
|
281
|
+
if (session.presentations.length > 0) {
|
|
282
|
+
logger.info('\nPresentation sequence (first 20):');
|
|
283
|
+
const sequence = session.presentations
|
|
284
|
+
.slice(0, 20)
|
|
285
|
+
.map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`)
|
|
286
|
+
.join('\n');
|
|
287
|
+
logger.info(sequence);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Detect clustering (same course in a row)
|
|
291
|
+
let maxCluster = 0;
|
|
292
|
+
let currentCluster = 1;
|
|
293
|
+
let currentCourse = session.presentations[0]?.courseId;
|
|
294
|
+
|
|
295
|
+
for (let i = 1; i < session.presentations.length; i++) {
|
|
296
|
+
if (session.presentations[i].courseId === currentCourse) {
|
|
297
|
+
currentCluster++;
|
|
298
|
+
maxCluster = Math.max(maxCluster, currentCluster);
|
|
299
|
+
} else {
|
|
300
|
+
currentCourse = session.presentations[i].courseId;
|
|
301
|
+
currentCluster = 1;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (maxCluster > 3) {
|
|
306
|
+
logger.info(`\n⚠️ Detected clustering: max ${maxCluster} cards from same course in a row`);
|
|
307
|
+
logger.info('This suggests cards are sorted by score rather than round-robin by course.');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// eslint-disable-next-line no-console
|
|
311
|
+
console.groupEnd();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Console API object exposed on window.skuilder.session
|
|
316
|
+
*/
|
|
317
|
+
export const sessionDebugAPI = {
|
|
318
|
+
/**
|
|
319
|
+
* Get raw session history for programmatic access.
|
|
320
|
+
*/
|
|
321
|
+
get sessions(): SessionRunReport[] {
|
|
322
|
+
return [...sessionHistory];
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get active session if any.
|
|
327
|
+
*/
|
|
328
|
+
get active(): SessionRunReport | null {
|
|
329
|
+
return activeSession;
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Show current queue state.
|
|
334
|
+
*/
|
|
335
|
+
showQueue(): void {
|
|
336
|
+
showCurrentQueue();
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Show presentation history for current or past session.
|
|
341
|
+
*/
|
|
342
|
+
showHistory(sessionIndex: number = 0): void {
|
|
343
|
+
showPresentationHistory(sessionIndex);
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Analyze course interleaving pattern.
|
|
348
|
+
*/
|
|
349
|
+
showInterleaving(sessionIndex: number = 0): void {
|
|
350
|
+
showInterleaving(sessionIndex);
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* List all tracked sessions.
|
|
355
|
+
*/
|
|
356
|
+
listSessions(): void {
|
|
357
|
+
if (activeSession) {
|
|
358
|
+
logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (sessionHistory.length === 0) {
|
|
362
|
+
logger.info('[Session Debug] No completed sessions in history.');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// eslint-disable-next-line no-console
|
|
367
|
+
console.table(
|
|
368
|
+
sessionHistory.map((s, idx) => ({
|
|
369
|
+
index: idx,
|
|
370
|
+
id: s.sessionId.slice(-8),
|
|
371
|
+
started: s.startTime.toLocaleTimeString(),
|
|
372
|
+
ended: s.endTime?.toLocaleTimeString() || 'incomplete',
|
|
373
|
+
cards: s.presentations.length,
|
|
374
|
+
}))
|
|
375
|
+
);
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Export session history as JSON for bug reports.
|
|
380
|
+
*/
|
|
381
|
+
export(): string {
|
|
382
|
+
const data = {
|
|
383
|
+
active: activeSession,
|
|
384
|
+
history: sessionHistory,
|
|
385
|
+
};
|
|
386
|
+
const json = JSON.stringify(data, null, 2);
|
|
387
|
+
logger.info('[Session Debug] Session data exported. Copy the returned string or use:');
|
|
388
|
+
logger.info(' copy(window.skuilder.session.export())');
|
|
389
|
+
return json;
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Clear session history.
|
|
394
|
+
*/
|
|
395
|
+
clear(): void {
|
|
396
|
+
sessionHistory.length = 0;
|
|
397
|
+
logger.info('[Session Debug] Session history cleared.');
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Show help.
|
|
402
|
+
*/
|
|
403
|
+
help(): void {
|
|
404
|
+
logger.info(`
|
|
405
|
+
🎯 Session Debug API
|
|
406
|
+
|
|
407
|
+
Commands:
|
|
408
|
+
.showQueue() Show current queue state (active session only)
|
|
409
|
+
.showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
|
|
410
|
+
.showInterleaving(index?) Analyze course interleaving pattern
|
|
411
|
+
.listSessions() List all tracked sessions
|
|
412
|
+
.export() Export session data as JSON for bug reports
|
|
413
|
+
.clear() Clear session history
|
|
414
|
+
.sessions Access raw session history array
|
|
415
|
+
.active Access active session (if any)
|
|
416
|
+
.help() Show this help message
|
|
417
|
+
|
|
418
|
+
Example:
|
|
419
|
+
window.skuilder.session.showHistory()
|
|
420
|
+
window.skuilder.session.showInterleaving()
|
|
421
|
+
window.skuilder.session.showQueue()
|
|
422
|
+
`);
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// ============================================================================
|
|
427
|
+
// WINDOW MOUNT
|
|
428
|
+
// ============================================================================
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Mount the debug API on window.skuilder.session
|
|
432
|
+
*/
|
|
433
|
+
export function mountSessionDebugger(): void {
|
|
434
|
+
if (typeof window === 'undefined') return;
|
|
435
|
+
|
|
436
|
+
const win = window as any;
|
|
437
|
+
win.skuilder = win.skuilder || {};
|
|
438
|
+
win.skuilder.session = sessionDebugAPI;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Auto-mount when module is loaded
|
|
442
|
+
mountSessionDebugger();
|