@vue-skuilder/db 0.1.24 → 0.1.26
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-BotbOOfX.d.ts → contentSource-BmnmvH8C.d.ts} +41 -0
- package/dist/{contentSource-C90LH-OH.d.cts → contentSource-DfBbaLA-.d.cts} +41 -0
- package/dist/core/index.d.cts +94 -4
- package/dist/core/index.d.ts +94 -4
- package/dist/core/index.js +530 -83
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +528 -83
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-DGKp4zFB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
- package/dist/{dataLayerProvider-SBpz9jQf.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +2 -2
- package/dist/impl/couch/index.d.ts +2 -2
- package/dist/impl/couch/index.js +526 -83
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +526 -83
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +2 -2
- package/dist/impl/static/index.d.ts +2 -2
- package/dist/impl/static/index.js +526 -83
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +526 -83
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +247 -14
- package/dist/index.d.ts +247 -14
- package/dist/index.js +1419 -140
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1409 -137
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +22 -4
- package/docs/todo-review-urgency-adaptation.md +205 -0
- package/package.json +3 -3
- package/src/core/interfaces/userDB.ts +44 -0
- package/src/core/navigators/Pipeline.ts +86 -5
- package/src/core/navigators/PipelineAssembler.ts +7 -21
- package/src/core/navigators/PipelineDebugger.ts +426 -0
- package/src/core/navigators/generators/CompositeGenerator.ts +21 -0
- package/src/core/navigators/generators/elo.ts +14 -1
- package/src/core/navigators/generators/srs.ts +146 -18
- package/src/core/navigators/index.ts +9 -0
- package/src/impl/couch/user-course-relDB.ts +12 -0
- package/src/study/MixerDebugger.ts +555 -0
- package/src/study/SessionController.ts +95 -19
- 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/PipelineAssembler.test.ts +4 -4
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import type { WeightedCard } from '@db/core/navigators';
|
|
2
|
+
import type { SourceBatch } from './SourceMixer';
|
|
3
|
+
import { logger } from '../util/logger';
|
|
4
|
+
import { getCardOrigin } from '@db/core/navigators';
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// MIXER DEBUGGER
|
|
8
|
+
// ============================================================================
|
|
9
|
+
//
|
|
10
|
+
// Console-accessible debug API for inspecting cross-source mixing decisions.
|
|
11
|
+
//
|
|
12
|
+
// Exposed as `window.skuilder.mixer` for interactive exploration.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// window.skuilder.mixer.showLastMix()
|
|
16
|
+
// window.skuilder.mixer.explainSourceBalance()
|
|
17
|
+
// window.skuilder.mixer.showCard('cardId123')
|
|
18
|
+
// window.skuilder.mixer.compareScores()
|
|
19
|
+
// window.skuilder.mixer.export()
|
|
20
|
+
//
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Summary of a single source's contribution to the mix.
|
|
25
|
+
*/
|
|
26
|
+
export interface SourceSummary {
|
|
27
|
+
sourceIndex: number;
|
|
28
|
+
sourceId: string;
|
|
29
|
+
sourceName?: string;
|
|
30
|
+
totalCards: number;
|
|
31
|
+
reviewCount: number;
|
|
32
|
+
newCount: number;
|
|
33
|
+
topScore: number;
|
|
34
|
+
bottomScore: number;
|
|
35
|
+
scoreRange: [number, number];
|
|
36
|
+
avgScore: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-source selection breakdown.
|
|
41
|
+
*/
|
|
42
|
+
export interface SourceSelectionBreakdown {
|
|
43
|
+
sourceId: string;
|
|
44
|
+
sourceName?: string;
|
|
45
|
+
reviewsProvided: number;
|
|
46
|
+
newProvided: number;
|
|
47
|
+
reviewsSelected: number;
|
|
48
|
+
newSelected: number;
|
|
49
|
+
totalSelected: number;
|
|
50
|
+
selectionRate: number; // percentage
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Detailed card information in the mixer context.
|
|
55
|
+
*/
|
|
56
|
+
export interface MixerCardInfo {
|
|
57
|
+
cardId: string;
|
|
58
|
+
courseId: string;
|
|
59
|
+
origin: 'review' | 'new' | 'failed' | 'unknown';
|
|
60
|
+
score: number;
|
|
61
|
+
sourceIndex: number;
|
|
62
|
+
selected: boolean;
|
|
63
|
+
rankInSource?: number; // 1-indexed position within its source batch
|
|
64
|
+
rankInMix?: number; // 1-indexed position in final mixed results
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Complete record of a single mixer execution.
|
|
69
|
+
*/
|
|
70
|
+
export interface MixerRunReport {
|
|
71
|
+
runId: string;
|
|
72
|
+
timestamp: Date;
|
|
73
|
+
|
|
74
|
+
// Mixer configuration
|
|
75
|
+
mixerType: string;
|
|
76
|
+
requestedLimit: number;
|
|
77
|
+
quotaPerSource?: number;
|
|
78
|
+
|
|
79
|
+
// Input batches
|
|
80
|
+
sourceSummaries: SourceSummary[];
|
|
81
|
+
|
|
82
|
+
// Selection results
|
|
83
|
+
cards: MixerCardInfo[];
|
|
84
|
+
finalCount: number;
|
|
85
|
+
reviewsSelected: number;
|
|
86
|
+
newSelected: number;
|
|
87
|
+
|
|
88
|
+
// Per-source breakdown
|
|
89
|
+
sourceBreakdowns: SourceSelectionBreakdown[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ring buffer for storing recent mixer runs.
|
|
94
|
+
*/
|
|
95
|
+
const MAX_RUNS = 10;
|
|
96
|
+
const runHistory: MixerRunReport[] = [];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build source summary from a batch.
|
|
100
|
+
*/
|
|
101
|
+
function buildSourceSummary(batch: SourceBatch, sourceId: string, sourceName?: string): SourceSummary {
|
|
102
|
+
const scores = batch.weighted.map((c) => c.score);
|
|
103
|
+
const reviewCount = batch.weighted.filter((c) => getCardOrigin(c) === 'review').length;
|
|
104
|
+
const newCount = batch.weighted.filter((c) => getCardOrigin(c) === 'new').length;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
sourceIndex: batch.sourceIndex,
|
|
108
|
+
sourceId,
|
|
109
|
+
sourceName,
|
|
110
|
+
totalCards: batch.weighted.length,
|
|
111
|
+
reviewCount,
|
|
112
|
+
newCount,
|
|
113
|
+
topScore: scores.length > 0 ? Math.max(...scores) : 0,
|
|
114
|
+
bottomScore: scores.length > 0 ? Math.min(...scores) : 0,
|
|
115
|
+
scoreRange: scores.length > 0 ? [Math.min(...scores), Math.max(...scores)] : [0, 0],
|
|
116
|
+
avgScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build source selection breakdown.
|
|
122
|
+
*/
|
|
123
|
+
function buildSourceBreakdown(
|
|
124
|
+
sourceId: string,
|
|
125
|
+
sourceName: string | undefined,
|
|
126
|
+
allCards: MixerCardInfo[]
|
|
127
|
+
): SourceSelectionBreakdown {
|
|
128
|
+
const sourceCards = allCards.filter((c) => c.courseId === sourceId);
|
|
129
|
+
const selectedCards = sourceCards.filter((c) => c.selected);
|
|
130
|
+
|
|
131
|
+
const reviewsProvided = sourceCards.filter((c) => c.origin === 'review').length;
|
|
132
|
+
const newProvided = sourceCards.filter((c) => c.origin === 'new').length;
|
|
133
|
+
const reviewsSelected = selectedCards.filter((c) => c.origin === 'review').length;
|
|
134
|
+
const newSelected = selectedCards.filter((c) => c.origin === 'new').length;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
sourceId,
|
|
138
|
+
sourceName,
|
|
139
|
+
reviewsProvided,
|
|
140
|
+
newProvided,
|
|
141
|
+
reviewsSelected,
|
|
142
|
+
newSelected,
|
|
143
|
+
totalSelected: selectedCards.length,
|
|
144
|
+
selectionRate: sourceCards.length > 0 ? (selectedCards.length / sourceCards.length) * 100 : 0,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Capture a mixer run for later inspection.
|
|
150
|
+
*/
|
|
151
|
+
export function captureMixerRun(
|
|
152
|
+
mixerType: string,
|
|
153
|
+
batches: SourceBatch[],
|
|
154
|
+
sourceIds: string[],
|
|
155
|
+
sourceNames: (string | undefined)[],
|
|
156
|
+
requestedLimit: number,
|
|
157
|
+
quotaPerSource: number | undefined,
|
|
158
|
+
mixedResult: WeightedCard[]
|
|
159
|
+
): void {
|
|
160
|
+
// Build source summaries
|
|
161
|
+
const sourceSummaries = batches.map((batch, idx) =>
|
|
162
|
+
buildSourceSummary(batch, sourceIds[idx] || `source-${idx}`, sourceNames[idx])
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Build card info with rankings
|
|
166
|
+
const selectedIds = new Set(mixedResult.map((c) => c.cardId));
|
|
167
|
+
|
|
168
|
+
// Rank cards within their source batches
|
|
169
|
+
const sourceRankings = new Map<string, Map<string, number>>();
|
|
170
|
+
batches.forEach((batch) => {
|
|
171
|
+
const sorted = [...batch.weighted].sort((a, b) => b.score - a.score);
|
|
172
|
+
const rankings = new Map<string, number>();
|
|
173
|
+
sorted.forEach((card, idx) => {
|
|
174
|
+
rankings.set(card.cardId, idx + 1);
|
|
175
|
+
});
|
|
176
|
+
sourceRankings.set(sourceIds[batch.sourceIndex] || `source-${batch.sourceIndex}`, rankings);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Rank cards in final mix
|
|
180
|
+
const mixRankings = new Map<string, number>();
|
|
181
|
+
mixedResult.forEach((card, idx) => {
|
|
182
|
+
mixRankings.set(card.cardId, idx + 1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Build all card info
|
|
186
|
+
const allCardsMap = new Map<string, WeightedCard>();
|
|
187
|
+
batches.forEach((batch) => {
|
|
188
|
+
batch.weighted.forEach((card) => {
|
|
189
|
+
allCardsMap.set(card.cardId, card);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const cards: MixerCardInfo[] = Array.from(allCardsMap.values()).map((card) => ({
|
|
194
|
+
cardId: card.cardId,
|
|
195
|
+
courseId: card.courseId,
|
|
196
|
+
origin: getCardOrigin(card),
|
|
197
|
+
score: card.score,
|
|
198
|
+
sourceIndex: batches.findIndex((b) => b.weighted.some((c) => c.cardId === card.cardId)),
|
|
199
|
+
selected: selectedIds.has(card.cardId),
|
|
200
|
+
rankInSource: sourceRankings.get(card.courseId)?.get(card.cardId),
|
|
201
|
+
rankInMix: mixRankings.get(card.cardId),
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
// Build per-source breakdowns
|
|
205
|
+
const uniqueSourceIds = Array.from(new Set(sourceIds.filter((id) => id)));
|
|
206
|
+
const sourceBreakdowns = uniqueSourceIds.map((sourceId, idx) =>
|
|
207
|
+
buildSourceBreakdown(sourceId, sourceNames[idx], cards)
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const report: MixerRunReport = {
|
|
211
|
+
runId: `mix-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
212
|
+
timestamp: new Date(),
|
|
213
|
+
mixerType,
|
|
214
|
+
requestedLimit,
|
|
215
|
+
quotaPerSource,
|
|
216
|
+
sourceSummaries,
|
|
217
|
+
cards,
|
|
218
|
+
finalCount: mixedResult.length,
|
|
219
|
+
reviewsSelected: mixedResult.filter((c) => getCardOrigin(c) === 'review').length,
|
|
220
|
+
newSelected: mixedResult.filter((c) => getCardOrigin(c) === 'new').length,
|
|
221
|
+
sourceBreakdowns,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
runHistory.unshift(report);
|
|
225
|
+
if (runHistory.length > MAX_RUNS) {
|
|
226
|
+
runHistory.pop();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// CONSOLE API
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Print summary of a single mixer run.
|
|
236
|
+
*/
|
|
237
|
+
function printMixerSummary(run: MixerRunReport): void {
|
|
238
|
+
// eslint-disable-next-line no-console
|
|
239
|
+
console.group(`🎨 Mixer Run: ${run.mixerType}`);
|
|
240
|
+
logger.info(`Run ID: ${run.runId}`);
|
|
241
|
+
logger.info(`Time: ${run.timestamp.toISOString()}`);
|
|
242
|
+
logger.info(
|
|
243
|
+
`Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ''}`
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// eslint-disable-next-line no-console
|
|
247
|
+
console.group(`📥 Input: ${run.sourceSummaries.length} sources`);
|
|
248
|
+
for (const src of run.sourceSummaries) {
|
|
249
|
+
logger.info(
|
|
250
|
+
` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
|
|
251
|
+
);
|
|
252
|
+
logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
|
|
253
|
+
}
|
|
254
|
+
// eslint-disable-next-line no-console
|
|
255
|
+
console.groupEnd();
|
|
256
|
+
|
|
257
|
+
// eslint-disable-next-line no-console
|
|
258
|
+
console.group(`📤 Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
|
|
259
|
+
for (const breakdown of run.sourceBreakdowns) {
|
|
260
|
+
const name = breakdown.sourceName || breakdown.sourceId;
|
|
261
|
+
logger.info(
|
|
262
|
+
` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
// eslint-disable-next-line no-console
|
|
266
|
+
console.groupEnd();
|
|
267
|
+
|
|
268
|
+
// eslint-disable-next-line no-console
|
|
269
|
+
console.groupEnd();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Console API object exposed on window.skuilder.mixer
|
|
274
|
+
*/
|
|
275
|
+
export const mixerDebugAPI = {
|
|
276
|
+
/**
|
|
277
|
+
* Get raw run history for programmatic access.
|
|
278
|
+
*/
|
|
279
|
+
get runs(): MixerRunReport[] {
|
|
280
|
+
return [...runHistory];
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Show summary of a specific mixer run.
|
|
285
|
+
*/
|
|
286
|
+
showRun(idOrIndex: string | number = 0): void {
|
|
287
|
+
if (runHistory.length === 0) {
|
|
288
|
+
logger.info('[Mixer Debug] No runs captured yet.');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let run: MixerRunReport | undefined;
|
|
293
|
+
|
|
294
|
+
if (typeof idOrIndex === 'number') {
|
|
295
|
+
run = runHistory[idOrIndex];
|
|
296
|
+
if (!run) {
|
|
297
|
+
logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
|
|
302
|
+
if (!run) {
|
|
303
|
+
logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
printMixerSummary(run);
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Show summary of the last mixer run.
|
|
313
|
+
*/
|
|
314
|
+
showLastMix(): void {
|
|
315
|
+
this.showRun(0);
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Explain source balance in the last run.
|
|
320
|
+
*/
|
|
321
|
+
explainSourceBalance(): void {
|
|
322
|
+
if (runHistory.length === 0) {
|
|
323
|
+
logger.info('[Mixer Debug] No runs captured yet.');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const run = runHistory[0];
|
|
328
|
+
|
|
329
|
+
// eslint-disable-next-line no-console
|
|
330
|
+
console.group('⚖️ Source Balance Analysis');
|
|
331
|
+
|
|
332
|
+
logger.info(`Mixer: ${run.mixerType}`);
|
|
333
|
+
logger.info(`Requested limit: ${run.requestedLimit}`);
|
|
334
|
+
if (run.quotaPerSource) {
|
|
335
|
+
logger.info(`Quota per source: ${run.quotaPerSource}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// eslint-disable-next-line no-console
|
|
339
|
+
console.group('Input Distribution:');
|
|
340
|
+
for (const src of run.sourceSummaries) {
|
|
341
|
+
const name = src.sourceName || src.sourceId;
|
|
342
|
+
logger.info(`${name}:`);
|
|
343
|
+
logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
|
|
344
|
+
logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
|
|
345
|
+
}
|
|
346
|
+
// eslint-disable-next-line no-console
|
|
347
|
+
console.groupEnd();
|
|
348
|
+
|
|
349
|
+
// eslint-disable-next-line no-console
|
|
350
|
+
console.group('Selection Results:');
|
|
351
|
+
for (const breakdown of run.sourceBreakdowns) {
|
|
352
|
+
const name = breakdown.sourceName || breakdown.sourceId;
|
|
353
|
+
logger.info(`${name}:`);
|
|
354
|
+
logger.info(
|
|
355
|
+
` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
|
|
356
|
+
);
|
|
357
|
+
logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
|
|
358
|
+
logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
|
|
359
|
+
|
|
360
|
+
// Identify potential issues
|
|
361
|
+
if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
|
|
362
|
+
logger.info(` ⚠️ Had reviews but none selected!`);
|
|
363
|
+
}
|
|
364
|
+
if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
|
|
365
|
+
logger.info(` ⚠️ Had cards but none selected!`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// eslint-disable-next-line no-console
|
|
369
|
+
console.groupEnd();
|
|
370
|
+
|
|
371
|
+
// Check for imbalances
|
|
372
|
+
const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
|
|
373
|
+
const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
|
|
374
|
+
const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
|
|
375
|
+
|
|
376
|
+
if (maxDeviation > 20) {
|
|
377
|
+
logger.info(`\n⚠️ Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
|
|
378
|
+
logger.info('Possible causes:');
|
|
379
|
+
logger.info(' - Score range differences between sources');
|
|
380
|
+
logger.info(' - One source has much better quality cards');
|
|
381
|
+
logger.info(' - Different card availability (reviews vs new)');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// eslint-disable-next-line no-console
|
|
385
|
+
console.groupEnd();
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Compare score distributions across sources.
|
|
390
|
+
*/
|
|
391
|
+
compareScores(): void {
|
|
392
|
+
if (runHistory.length === 0) {
|
|
393
|
+
logger.info('[Mixer Debug] No runs captured yet.');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const run = runHistory[0];
|
|
398
|
+
|
|
399
|
+
// eslint-disable-next-line no-console
|
|
400
|
+
console.group('📊 Score Distribution Comparison');
|
|
401
|
+
|
|
402
|
+
// eslint-disable-next-line no-console
|
|
403
|
+
console.table(
|
|
404
|
+
run.sourceSummaries.map((src) => ({
|
|
405
|
+
source: src.sourceName || src.sourceId,
|
|
406
|
+
cards: src.totalCards,
|
|
407
|
+
min: src.bottomScore.toFixed(3),
|
|
408
|
+
max: src.topScore.toFixed(3),
|
|
409
|
+
avg: src.avgScore.toFixed(3),
|
|
410
|
+
range: (src.topScore - src.bottomScore).toFixed(3),
|
|
411
|
+
}))
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// Check for score normalization issues
|
|
415
|
+
const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
|
|
416
|
+
const avgScores = run.sourceSummaries.map((s) => s.avgScore);
|
|
417
|
+
|
|
418
|
+
const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
|
|
419
|
+
const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
|
|
420
|
+
|
|
421
|
+
if (rangeDiff > 0.3 || avgDiff > 0.2) {
|
|
422
|
+
logger.info('\n⚠️ Significant score distribution differences detected');
|
|
423
|
+
logger.info(
|
|
424
|
+
'This may cause one source to dominate selection if using global sorting (not quota-based)'
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// eslint-disable-next-line no-console
|
|
429
|
+
console.groupEnd();
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Show detailed information for a specific card.
|
|
434
|
+
*/
|
|
435
|
+
showCard(cardId: string): void {
|
|
436
|
+
for (const run of runHistory) {
|
|
437
|
+
const card = run.cards.find((c) => c.cardId === cardId);
|
|
438
|
+
if (card) {
|
|
439
|
+
const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
|
|
440
|
+
|
|
441
|
+
// eslint-disable-next-line no-console
|
|
442
|
+
console.group(`🎴 Card: ${cardId}`);
|
|
443
|
+
logger.info(`Course: ${card.courseId}`);
|
|
444
|
+
logger.info(`Source: ${source?.sourceName || source?.sourceId || 'unknown'}`);
|
|
445
|
+
logger.info(`Origin: ${card.origin}`);
|
|
446
|
+
logger.info(`Score: ${card.score.toFixed(3)}`);
|
|
447
|
+
if (card.rankInSource) {
|
|
448
|
+
logger.info(`Rank in source: #${card.rankInSource}`);
|
|
449
|
+
}
|
|
450
|
+
if (card.rankInMix) {
|
|
451
|
+
logger.info(`Rank in mixed results: #${card.rankInMix}`);
|
|
452
|
+
}
|
|
453
|
+
logger.info(`Selected: ${card.selected ? 'Yes ✅' : 'No ❌'}`);
|
|
454
|
+
|
|
455
|
+
if (!card.selected && card.rankInSource) {
|
|
456
|
+
logger.info('\nWhy not selected:');
|
|
457
|
+
if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
|
|
458
|
+
logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
|
|
459
|
+
}
|
|
460
|
+
logger.info(' - Check score compared to selected cards using .showRun()');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// eslint-disable-next-line no-console
|
|
464
|
+
console.groupEnd();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Show all runs in compact format.
|
|
473
|
+
*/
|
|
474
|
+
listRuns(): void {
|
|
475
|
+
if (runHistory.length === 0) {
|
|
476
|
+
logger.info('[Mixer Debug] No runs captured yet.');
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// eslint-disable-next-line no-console
|
|
481
|
+
console.table(
|
|
482
|
+
runHistory.map((r) => ({
|
|
483
|
+
id: r.runId.slice(-8),
|
|
484
|
+
time: r.timestamp.toLocaleTimeString(),
|
|
485
|
+
mixer: r.mixerType,
|
|
486
|
+
sources: r.sourceSummaries.length,
|
|
487
|
+
selected: r.finalCount,
|
|
488
|
+
reviews: r.reviewsSelected,
|
|
489
|
+
new: r.newSelected,
|
|
490
|
+
}))
|
|
491
|
+
);
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Export run history as JSON for bug reports.
|
|
496
|
+
*/
|
|
497
|
+
export(): string {
|
|
498
|
+
const json = JSON.stringify(runHistory, null, 2);
|
|
499
|
+
logger.info('[Mixer Debug] Run history exported. Copy the returned string or use:');
|
|
500
|
+
logger.info(' copy(window.skuilder.mixer.export())');
|
|
501
|
+
return json;
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Clear run history.
|
|
506
|
+
*/
|
|
507
|
+
clear(): void {
|
|
508
|
+
runHistory.length = 0;
|
|
509
|
+
logger.info('[Mixer Debug] Run history cleared.');
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Show help.
|
|
514
|
+
*/
|
|
515
|
+
help(): void {
|
|
516
|
+
logger.info(`
|
|
517
|
+
🎨 Mixer Debug API
|
|
518
|
+
|
|
519
|
+
Commands:
|
|
520
|
+
.showLastMix() Show summary of most recent mixer run
|
|
521
|
+
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
522
|
+
.explainSourceBalance() Analyze source balance and selection patterns
|
|
523
|
+
.compareScores() Compare score distributions across sources
|
|
524
|
+
.showCard(cardId) Show mixer decisions for a specific card
|
|
525
|
+
.listRuns() List all captured runs in table format
|
|
526
|
+
.export() Export run history as JSON for bug reports
|
|
527
|
+
.clear() Clear run history
|
|
528
|
+
.runs Access raw run history array
|
|
529
|
+
.help() Show this help message
|
|
530
|
+
|
|
531
|
+
Example:
|
|
532
|
+
window.skuilder.mixer.showLastMix()
|
|
533
|
+
window.skuilder.mixer.explainSourceBalance()
|
|
534
|
+
window.skuilder.mixer.compareScores()
|
|
535
|
+
`);
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// ============================================================================
|
|
540
|
+
// WINDOW MOUNT
|
|
541
|
+
// ============================================================================
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Mount the debug API on window.skuilder.mixer
|
|
545
|
+
*/
|
|
546
|
+
export function mountMixerDebugger(): void {
|
|
547
|
+
if (typeof window === 'undefined') return;
|
|
548
|
+
|
|
549
|
+
const win = window as any;
|
|
550
|
+
win.skuilder = win.skuilder || {};
|
|
551
|
+
win.skuilder.mixer = mixerDebugAPI;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Auto-mount when module is loaded
|
|
555
|
+
mountMixerDebugger();
|