@vue-skuilder/db 0.1.24 → 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.
Files changed (50) hide show
  1. package/dist/{contentSource-BotbOOfX.d.ts → contentSource-BmnmvH8C.d.ts} +41 -0
  2. package/dist/{contentSource-C90LH-OH.d.cts → contentSource-DfBbaLA-.d.cts} +41 -0
  3. package/dist/core/index.d.cts +94 -4
  4. package/dist/core/index.d.ts +94 -4
  5. package/dist/core/index.js +530 -83
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +528 -83
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-DGKp4zFB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-SBpz9jQf.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +2 -2
  12. package/dist/impl/couch/index.d.ts +2 -2
  13. package/dist/impl/couch/index.js +526 -83
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +526 -83
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +2 -2
  18. package/dist/impl/static/index.d.ts +2 -2
  19. package/dist/impl/static/index.js +526 -83
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +526 -83
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +247 -14
  24. package/dist/index.d.ts +247 -14
  25. package/dist/index.js +1419 -140
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +1409 -137
  28. package/dist/index.mjs.map +1 -1
  29. package/docs/navigators-architecture.md +22 -4
  30. package/docs/todo-review-urgency-adaptation.md +205 -0
  31. package/package.json +3 -3
  32. package/src/core/interfaces/userDB.ts +44 -0
  33. package/src/core/navigators/Pipeline.ts +86 -5
  34. package/src/core/navigators/PipelineAssembler.ts +7 -21
  35. package/src/core/navigators/PipelineDebugger.ts +426 -0
  36. package/src/core/navigators/generators/CompositeGenerator.ts +21 -0
  37. package/src/core/navigators/generators/elo.ts +14 -1
  38. package/src/core/navigators/generators/srs.ts +146 -18
  39. package/src/core/navigators/index.ts +9 -0
  40. package/src/impl/couch/user-course-relDB.ts +12 -0
  41. package/src/study/MixerDebugger.ts +555 -0
  42. package/src/study/SessionController.ts +95 -19
  43. package/src/study/SessionDebugger.ts +442 -0
  44. package/src/study/SourceMixer.ts +36 -17
  45. package/src/study/TODO-session-scheduling.md +133 -0
  46. package/src/study/index.ts +2 -0
  47. package/src/study/services/EloService.ts +79 -4
  48. package/src/study/services/ResponseProcessor.ts +130 -72
  49. package/src/study/services/SrsService.ts +9 -0
  50. package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
@@ -0,0 +1,426 @@
1
+ import type { WeightedCard, StrategyContribution } from './index';
2
+ import { logger } from '../../util/logger';
3
+
4
+ // ============================================================================
5
+ // PIPELINE DEBUGGER
6
+ // ============================================================================
7
+ //
8
+ // Console-accessible debug API for inspecting pipeline decisions.
9
+ //
10
+ // Exposed as `window.skuilder.pipeline` for interactive exploration.
11
+ //
12
+ // Usage:
13
+ // window.skuilder.pipeline.showLastRun()
14
+ // window.skuilder.pipeline.showCard('cardId123')
15
+ // window.skuilder.pipeline.explainReviews()
16
+ // window.skuilder.pipeline.export()
17
+ //
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Summary of a single generator's contribution.
22
+ */
23
+ export interface GeneratorSummary {
24
+ name: string;
25
+ cardCount: number;
26
+ newCount: number;
27
+ reviewCount: number;
28
+ topScore: number;
29
+ }
30
+
31
+ /**
32
+ * Summary of a filter's impact on scores.
33
+ */
34
+ export interface FilterImpact {
35
+ name: string;
36
+ boosted: number;
37
+ penalized: number;
38
+ passed: number;
39
+ removed: number;
40
+ }
41
+
42
+ /**
43
+ * Complete record of a single pipeline execution.
44
+ */
45
+ export interface PipelineRunReport {
46
+ runId: string;
47
+ timestamp: Date;
48
+ courseId: string;
49
+ courseName?: string;
50
+
51
+ // Generator phase
52
+ generatorName: string;
53
+ generators?: GeneratorSummary[];
54
+ generatedCount: number;
55
+
56
+ // Filter phase
57
+ filters: FilterImpact[];
58
+
59
+ // Results
60
+ finalCount: number;
61
+ reviewsSelected: number;
62
+ newSelected: number;
63
+
64
+ // Full card data for inspection
65
+ cards: Array<{
66
+ cardId: string;
67
+ courseId: string;
68
+ origin: 'new' | 'review' | 'unknown';
69
+ finalScore: number;
70
+ provenance: StrategyContribution[];
71
+ selected: boolean;
72
+ }>;
73
+ }
74
+
75
+ /**
76
+ * Ring buffer for storing recent pipeline runs.
77
+ */
78
+ const MAX_RUNS = 10;
79
+ const runHistory: PipelineRunReport[] = [];
80
+
81
+ /**
82
+ * Determine card origin from provenance trail.
83
+ */
84
+ function getOrigin(card: WeightedCard): 'new' | 'review' | 'unknown' {
85
+ const firstEntry = card.provenance[0];
86
+ if (!firstEntry) return 'unknown';
87
+ const reason = firstEntry.reason?.toLowerCase() || '';
88
+ if (reason.includes('new card')) return 'new';
89
+ if (reason.includes('review')) return 'review';
90
+ return 'unknown';
91
+ }
92
+
93
+ /**
94
+ * Capture a pipeline run for later inspection.
95
+ */
96
+ export function captureRun(report: Omit<PipelineRunReport, 'runId' | 'timestamp'>): void {
97
+ const fullReport: PipelineRunReport = {
98
+ ...report,
99
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
100
+ timestamp: new Date(),
101
+ };
102
+
103
+ runHistory.unshift(fullReport);
104
+ if (runHistory.length > MAX_RUNS) {
105
+ runHistory.pop();
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Build a capture-ready report from pipeline execution data.
111
+ */
112
+ export function buildRunReport(
113
+ courseId: string,
114
+ courseName: string | undefined,
115
+ generatorName: string,
116
+ generators: GeneratorSummary[] | undefined,
117
+ generatedCount: number,
118
+ filters: FilterImpact[],
119
+ allCards: WeightedCard[],
120
+ selectedCards: WeightedCard[]
121
+ ): Omit<PipelineRunReport, 'runId' | 'timestamp'> {
122
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
123
+
124
+ const cards = allCards.map((card) => ({
125
+ cardId: card.cardId,
126
+ courseId: card.courseId,
127
+ origin: getOrigin(card),
128
+ finalScore: card.score,
129
+ provenance: card.provenance,
130
+ selected: selectedIds.has(card.cardId),
131
+ }));
132
+
133
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === 'review').length;
134
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === 'new').length;
135
+
136
+ return {
137
+ courseId,
138
+ courseName,
139
+ generatorName,
140
+ generators,
141
+ generatedCount,
142
+ filters,
143
+ finalCount: selectedCards.length,
144
+ reviewsSelected,
145
+ newSelected,
146
+ cards,
147
+ };
148
+ }
149
+
150
+ // ============================================================================
151
+ // CONSOLE API
152
+ // ============================================================================
153
+
154
+ /**
155
+ * Format a provenance trail for console display.
156
+ */
157
+ function formatProvenance(provenance: StrategyContribution[]): string {
158
+ return provenance
159
+ .map((p) => {
160
+ const actionSymbol =
161
+ p.action === 'generated'
162
+ ? '🎲'
163
+ : p.action === 'boosted'
164
+ ? '⬆️'
165
+ : p.action === 'penalized'
166
+ ? '⬇️'
167
+ : '➡️';
168
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
169
+ })
170
+ .join('\n');
171
+ }
172
+
173
+ /**
174
+ * Print summary of a single pipeline run.
175
+ */
176
+ function printRunSummary(run: PipelineRunReport): void {
177
+ // eslint-disable-next-line no-console
178
+ console.group(`🔍 Pipeline Run: ${run.courseId} (${run.courseName || 'unnamed'})`);
179
+ logger.info(`Run ID: ${run.runId}`);
180
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
181
+ logger.info(`Generator: ${run.generatorName} → ${run.generatedCount} candidates`);
182
+
183
+ if (run.generators && run.generators.length > 0) {
184
+ // eslint-disable-next-line no-console
185
+ console.group('Generator breakdown:');
186
+ for (const g of run.generators) {
187
+ logger.info(
188
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
189
+ );
190
+ }
191
+ // eslint-disable-next-line no-console
192
+ console.groupEnd();
193
+ }
194
+
195
+ if (run.filters.length > 0) {
196
+ // eslint-disable-next-line no-console
197
+ console.group('Filter impact:');
198
+ for (const f of run.filters) {
199
+ logger.info(` ${f.name}: ↑${f.boosted} ↓${f.penalized} =${f.passed} ✕${f.removed}`);
200
+ }
201
+ // eslint-disable-next-line no-console
202
+ console.groupEnd();
203
+ }
204
+
205
+ logger.info(
206
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
207
+ );
208
+ // eslint-disable-next-line no-console
209
+ console.groupEnd();
210
+ }
211
+
212
+ /**
213
+ * Console API object exposed on window.skuilder.pipeline
214
+ */
215
+ export const pipelineDebugAPI = {
216
+ /**
217
+ * Get raw run history for programmatic access.
218
+ */
219
+ get runs(): PipelineRunReport[] {
220
+ return [...runHistory];
221
+ },
222
+
223
+ /**
224
+ * Show summary of a specific pipeline run.
225
+ */
226
+ showRun(idOrIndex: string | number = 0): void {
227
+ if (runHistory.length === 0) {
228
+ logger.info('[Pipeline Debug] No runs captured yet.');
229
+ return;
230
+ }
231
+
232
+ let run: PipelineRunReport | undefined;
233
+
234
+ if (typeof idOrIndex === 'number') {
235
+ run = runHistory[idOrIndex];
236
+ if (!run) {
237
+ logger.info(
238
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
239
+ );
240
+ return;
241
+ }
242
+ } else {
243
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
244
+ if (!run) {
245
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
246
+ return;
247
+ }
248
+ }
249
+
250
+ printRunSummary(run);
251
+ },
252
+
253
+ /**
254
+ * Show summary of the last pipeline run.
255
+ */
256
+ showLastRun(): void {
257
+ this.showRun(0);
258
+ },
259
+
260
+ /**
261
+ * Show detailed provenance for a specific card.
262
+ */
263
+ showCard(cardId: string): void {
264
+ for (const run of runHistory) {
265
+ const card = run.cards.find((c) => c.cardId === cardId);
266
+ if (card) {
267
+ // eslint-disable-next-line no-console
268
+ console.group(`🎴 Card: ${cardId}`);
269
+ logger.info(`Course: ${card.courseId}`);
270
+ logger.info(`Origin: ${card.origin}`);
271
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
272
+ logger.info(`Selected: ${card.selected ? 'Yes ✅' : 'No ❌'}`);
273
+ logger.info('Provenance:');
274
+ logger.info(formatProvenance(card.provenance));
275
+ // eslint-disable-next-line no-console
276
+ console.groupEnd();
277
+ return;
278
+ }
279
+ }
280
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
281
+ },
282
+
283
+ /**
284
+ * Explain why reviews may or may not have been selected.
285
+ */
286
+ explainReviews(): void {
287
+ if (runHistory.length === 0) {
288
+ logger.info('[Pipeline Debug] No runs captured yet.');
289
+ return;
290
+ }
291
+
292
+ // eslint-disable-next-line no-console
293
+ console.group('📋 Review Selection Analysis');
294
+
295
+ for (const run of runHistory) {
296
+ // eslint-disable-next-line no-console
297
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
298
+
299
+ const allReviews = run.cards.filter((c) => c.origin === 'review');
300
+ const selectedReviews = allReviews.filter((c) => c.selected);
301
+
302
+ if (allReviews.length === 0) {
303
+ logger.info('❌ No reviews were generated. Check SRS logs for why.');
304
+ } else if (selectedReviews.length === 0) {
305
+ logger.info(`⚠️ ${allReviews.length} reviews generated but none selected.`);
306
+ logger.info('Possible reasons:');
307
+
308
+ // Check if new cards scored higher
309
+ const topNewScore = Math.max(
310
+ ...run.cards.filter((c) => c.origin === 'new' && c.selected).map((c) => c.finalScore),
311
+ 0
312
+ );
313
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
314
+
315
+ if (topReviewScore < topNewScore) {
316
+ logger.info(
317
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
318
+ );
319
+ }
320
+
321
+ // Show top review that didn't make it
322
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
323
+ if (topReview) {
324
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
325
+ logger.info(' - Its provenance:');
326
+ logger.info(formatProvenance(topReview.provenance));
327
+ }
328
+ } else {
329
+ logger.info(`✅ ${selectedReviews.length}/${allReviews.length} reviews selected.`);
330
+ logger.info('Top selected review:');
331
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
332
+ logger.info(formatProvenance(topSelected.provenance));
333
+ }
334
+
335
+ // eslint-disable-next-line no-console
336
+ console.groupEnd();
337
+ }
338
+
339
+ // eslint-disable-next-line no-console
340
+ console.groupEnd();
341
+ },
342
+
343
+ /**
344
+ * Show all runs in compact format.
345
+ */
346
+ listRuns(): void {
347
+ if (runHistory.length === 0) {
348
+ logger.info('[Pipeline Debug] No runs captured yet.');
349
+ return;
350
+ }
351
+
352
+ // eslint-disable-next-line no-console
353
+ console.table(
354
+ runHistory.map((r) => ({
355
+ id: r.runId.slice(-8),
356
+ time: r.timestamp.toLocaleTimeString(),
357
+ course: r.courseName || r.courseId.slice(0, 8),
358
+ generated: r.generatedCount,
359
+ selected: r.finalCount,
360
+ new: r.newSelected,
361
+ reviews: r.reviewsSelected,
362
+ }))
363
+ );
364
+ },
365
+
366
+ /**
367
+ * Export run history as JSON for bug reports.
368
+ */
369
+ export(): string {
370
+ const json = JSON.stringify(runHistory, null, 2);
371
+ logger.info('[Pipeline Debug] Run history exported. Copy the returned string or use:');
372
+ logger.info(' copy(window.skuilder.pipeline.export())');
373
+ return json;
374
+ },
375
+
376
+ /**
377
+ * Clear run history.
378
+ */
379
+ clear(): void {
380
+ runHistory.length = 0;
381
+ logger.info('[Pipeline Debug] Run history cleared.');
382
+ },
383
+
384
+ /**
385
+ * Show help.
386
+ */
387
+ help(): void {
388
+ logger.info(`
389
+ 🔧 Pipeline Debug API
390
+
391
+ Commands:
392
+ .showLastRun() Show summary of most recent pipeline run
393
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
394
+ .showCard(cardId) Show provenance trail for a specific card
395
+ .explainReviews() Analyze why reviews were/weren't selected
396
+ .listRuns() List all captured runs in table format
397
+ .export() Export run history as JSON for bug reports
398
+ .clear() Clear run history
399
+ .runs Access raw run history array
400
+ .help() Show this help message
401
+
402
+ Example:
403
+ window.skuilder.pipeline.showLastRun()
404
+ window.skuilder.pipeline.showRun(1)
405
+ window.skuilder.pipeline.showCard('abc123')
406
+ `);
407
+ },
408
+ };
409
+
410
+ // ============================================================================
411
+ // WINDOW MOUNT
412
+ // ============================================================================
413
+
414
+ /**
415
+ * Mount the debug API on window.skuilder.pipeline
416
+ */
417
+ export function mountPipelineDebugger(): void {
418
+ if (typeof window === 'undefined') return;
419
+
420
+ const win = window as any;
421
+ win.skuilder = win.skuilder || {};
422
+ win.skuilder.pipeline = pipelineDebugAPI;
423
+ }
424
+
425
+ // Auto-mount when module is loaded
426
+ mountPipelineDebugger();
@@ -113,6 +113,27 @@ export default class CompositeGenerator extends ContentNavigator implements Card
113
113
  this.generators.map((g) => g.getWeightedCards(limit, context))
114
114
  );
115
115
 
116
+ // Log per-generator breakdown for transparency
117
+ const generatorSummaries: string[] = [];
118
+ results.forEach((cards, index) => {
119
+ const gen = this.generators[index];
120
+ const genName = gen.name || `Generator ${index}`;
121
+ const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes('new card'));
122
+ const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes('review'));
123
+
124
+ if (cards.length > 0) {
125
+ const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
126
+ const parts: string[] = [];
127
+ if (newCards.length > 0) parts.push(`${newCards.length} new`);
128
+ if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
129
+ const breakdown = parts.length > 0 ? parts.join(', ') : `${cards.length} cards`;
130
+ generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
131
+ } else {
132
+ generatorSummaries.push(`${genName}: 0 cards`);
133
+ }
134
+ });
135
+ logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(' | ')}`);
136
+
116
137
  // Group by cardId, tracking the weight of the generator that produced each instance
117
138
  type WeightedResult = { card: WeightedCard; weight: number };
118
139
  const byCardId = new Map<string, WeightedResult[]>();
@@ -5,6 +5,7 @@ import type { WeightedCard } from '../index';
5
5
  import { toCourseElo } from '@vue-skuilder/common';
6
6
  import type { QualifiedCardID } from '../..';
7
7
  import type { CardGenerator, GeneratorContext } from './types';
8
+ import { logger } from '@db/util/logger';
8
9
 
9
10
  // ============================================================================
10
11
  // ELO NAVIGATOR
@@ -113,6 +114,18 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
113
114
  // Sort by score descending
114
115
  scored.sort((a, b) => b.score - a.score);
115
116
 
116
- return scored.slice(0, limit);
117
+ const result = scored.slice(0, limit);
118
+
119
+ // Log summary for transparency
120
+ if (result.length > 0) {
121
+ const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(', ');
122
+ logger.info(
123
+ `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
124
+ );
125
+ } else {
126
+ logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
127
+ }
128
+
129
+ return result;
117
130
  }
118
131
  }