@vue-skuilder/db 0.1.32-b → 0.1.32-e
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-DF1nUbPQ.d.cts → contentSource-BMlMwSiG.d.cts} +124 -5
- package/dist/{contentSource-Bdwkvqa8.d.ts → contentSource-Ht3N2f-y.d.ts} +124 -5
- package/dist/core/index.d.cts +26 -83
- package/dist/core/index.d.ts +26 -83
- package/dist/core/index.js +767 -71
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +767 -71
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BQdfJuBN.d.cts → dataLayerProvider-BEqB8VBR.d.cts} +1 -1
- package/dist/{dataLayerProvider-BKmVoyJR.d.ts → dataLayerProvider-DObSXjnf.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +18 -5
- package/dist/impl/couch/index.d.ts +18 -5
- package/dist/impl/couch/index.js +817 -74
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +817 -74
- 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 +763 -67
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +763 -67
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +23 -8
- package/dist/index.d.ts +23 -8
- package/dist/index.js +872 -86
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +872 -86
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +2 -2
- package/package.json +2 -2
- package/src/core/interfaces/contentSource.ts +3 -3
- package/src/core/navigators/Pipeline.ts +104 -32
- package/src/core/navigators/PipelineDebugger.ts +152 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +90 -6
- package/src/core/navigators/filters/interferenceMitigator.ts +2 -1
- package/src/core/navigators/filters/relativePriority.ts +2 -1
- package/src/core/navigators/filters/userTagPreference.ts +2 -1
- package/src/core/navigators/generators/CompositeGenerator.ts +58 -5
- package/src/core/navigators/generators/elo.ts +7 -7
- package/src/core/navigators/generators/prescribed.ts +710 -46
- package/src/core/navigators/generators/srs.ts +3 -4
- package/src/core/navigators/generators/types.ts +48 -2
- package/src/core/navigators/index.ts +4 -3
- package/src/impl/couch/CourseSyncService.ts +72 -4
- package/src/impl/couch/classroomDB.ts +4 -3
- package/src/impl/couch/courseDB.ts +5 -4
- package/src/impl/static/courseDB.ts +5 -4
- package/src/study/SessionController.ts +58 -10
- package/src/study/TagFilteredContentSource.ts +4 -3
- package/src/study/services/EloService.ts +22 -3
- package/src/study/services/ResponseProcessor.ts +7 -3
|
@@ -3,9 +3,8 @@ import type { ScheduledCard } from '../../types/user';
|
|
|
3
3
|
import type { CourseDBInterface } from '../../interfaces/courseDB';
|
|
4
4
|
import type { UserDBInterface } from '../../interfaces/userDB';
|
|
5
5
|
import { ContentNavigator } from '../index';
|
|
6
|
-
import type { WeightedCard } from '../index';
|
|
7
6
|
import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
|
|
8
|
-
import type { CardGenerator, GeneratorContext } from './types';
|
|
7
|
+
import type { CardGenerator, GeneratorContext, GeneratorResult } from './types';
|
|
9
8
|
import { logger } from '@db/util/logger';
|
|
10
9
|
|
|
11
10
|
// ============================================================================
|
|
@@ -122,7 +121,7 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
122
121
|
* @param limit - Maximum number of cards to return
|
|
123
122
|
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
124
123
|
*/
|
|
125
|
-
async getWeightedCards(limit: number, _context?: GeneratorContext): Promise<
|
|
124
|
+
async getWeightedCards(limit: number, _context?: GeneratorContext): Promise<GeneratorResult> {
|
|
126
125
|
if (!this.user || !this.course) {
|
|
127
126
|
throw new Error('SRSNavigator requires user and course to be set');
|
|
128
127
|
}
|
|
@@ -189,7 +188,7 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
189
188
|
});
|
|
190
189
|
|
|
191
190
|
// Sort by score descending and limit
|
|
192
|
-
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
191
|
+
return { cards: scored.sort((a, b) => b.score - a.score).slice(0, limit) };
|
|
193
192
|
}
|
|
194
193
|
|
|
195
194
|
/**
|
|
@@ -3,6 +3,30 @@ import type { CourseDBInterface } from '../../interfaces/courseDB';
|
|
|
3
3
|
import type { UserDBInterface } from '../../interfaces/userDB';
|
|
4
4
|
import type { OrchestrationContext } from '../../orchestration';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Typed ephemeral pipeline hints for a single run.
|
|
8
|
+
* All fields are optional. Tag/card patterns support `*` wildcards.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReplanHints {
|
|
11
|
+
/** Multiply scores for cards matching these tag patterns. */
|
|
12
|
+
boostTags?: Record<string, number>;
|
|
13
|
+
/** Multiply scores for these specific card IDs (glob patterns). */
|
|
14
|
+
boostCards?: Record<string, number>;
|
|
15
|
+
/** Cards matching these tag patterns MUST appear in results. */
|
|
16
|
+
requireTags?: string[];
|
|
17
|
+
/** These specific card IDs MUST appear in results. */
|
|
18
|
+
requireCards?: string[];
|
|
19
|
+
/** Remove cards matching these tag patterns from results. */
|
|
20
|
+
excludeTags?: string[];
|
|
21
|
+
/** Remove these specific card IDs from results. */
|
|
22
|
+
excludeCards?: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Debugging label threaded from the replan requester.
|
|
25
|
+
* Prefixed with `_` to signal it's metadata, not a scoring hint.
|
|
26
|
+
*/
|
|
27
|
+
_label?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
6
30
|
// ============================================================================
|
|
7
31
|
// CARD GENERATOR INTERFACE
|
|
8
32
|
// ============================================================================
|
|
@@ -44,6 +68,21 @@ export interface GeneratorContext {
|
|
|
44
68
|
// - session state (cards already seen this session)
|
|
45
69
|
}
|
|
46
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Structured generator result.
|
|
73
|
+
*
|
|
74
|
+
* Generators may optionally emit one-shot replan hints alongside their
|
|
75
|
+
* candidate cards. This allows a generator to shape the broader pipeline
|
|
76
|
+
* without having to enumerate every affected support card directly.
|
|
77
|
+
*/
|
|
78
|
+
export interface GeneratorResult {
|
|
79
|
+
/** Candidate cards produced by the generator */
|
|
80
|
+
cards: WeightedCard[];
|
|
81
|
+
|
|
82
|
+
/** Optional one-shot hints to apply after the filter chain */
|
|
83
|
+
hints?: ReplanHints;
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
/**
|
|
48
87
|
* A generator that produces candidate cards with initial scores.
|
|
49
88
|
*
|
|
@@ -65,6 +104,9 @@ export interface GeneratorContext {
|
|
|
65
104
|
*
|
|
66
105
|
* 4. **Sort before returning**: Return cards sorted by score descending.
|
|
67
106
|
*
|
|
107
|
+
* 5. **Hints are optional**: Generators may return structured results with
|
|
108
|
+
* `hints` when they need to apply pipeline-wide ephemeral pressure.
|
|
109
|
+
*
|
|
68
110
|
* ## Example Implementation
|
|
69
111
|
*
|
|
70
112
|
* ```typescript
|
|
@@ -98,9 +140,13 @@ export interface CardGenerator {
|
|
|
98
140
|
*
|
|
99
141
|
* @param limit - Maximum number of cards to return
|
|
100
142
|
* @param context - Shared context (user, course, userElo, etc.)
|
|
101
|
-
* @returns Cards sorted by score descending, with provenance
|
|
143
|
+
* @returns Cards sorted by score descending, with provenance, optionally
|
|
144
|
+
* accompanied by one-shot replan hints
|
|
102
145
|
*/
|
|
103
|
-
getWeightedCards(
|
|
146
|
+
getWeightedCards(
|
|
147
|
+
limit: number,
|
|
148
|
+
context: GeneratorContext
|
|
149
|
+
): Promise<GeneratorResult>;
|
|
104
150
|
}
|
|
105
151
|
|
|
106
152
|
/**
|
|
@@ -4,7 +4,8 @@ import { StudyContentSource, UserDBInterface, CourseDBInterface } from '..';
|
|
|
4
4
|
export type { CardFilter, FilterContext, CardFilterFactory } from './filters/types';
|
|
5
5
|
|
|
6
6
|
// Re-export generator types
|
|
7
|
-
export type { CardGenerator, GeneratorContext, CardGeneratorFactory } from './generators/types';
|
|
7
|
+
export type { CardGenerator, GeneratorContext, CardGeneratorFactory, GeneratorResult, ReplanHints } from './generators/types';
|
|
8
|
+
import type { GeneratorResult, ReplanHints } from './generators/types';
|
|
8
9
|
|
|
9
10
|
// Re-export pipeline debugger API
|
|
10
11
|
export {
|
|
@@ -625,7 +626,7 @@ export abstract class ContentNavigator implements StudyContentSource {
|
|
|
625
626
|
* @param limit - Maximum cards to return
|
|
626
627
|
* @returns Cards sorted by score descending, with provenance trails
|
|
627
628
|
*/
|
|
628
|
-
async getWeightedCards(_limit: number): Promise<
|
|
629
|
+
async getWeightedCards(_limit: number): Promise<GeneratorResult> {
|
|
629
630
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
630
631
|
}
|
|
631
632
|
|
|
@@ -633,7 +634,7 @@ export abstract class ContentNavigator implements StudyContentSource {
|
|
|
633
634
|
* Set ephemeral hints for the next pipeline run.
|
|
634
635
|
* No-op for non-Pipeline navigators. Pipeline overrides this.
|
|
635
636
|
*/
|
|
636
|
-
setEphemeralHints(_hints:
|
|
637
|
+
setEphemeralHints(_hints: ReplanHints): void {
|
|
637
638
|
// no-op — only Pipeline implements this
|
|
638
639
|
}
|
|
639
640
|
}
|
|
@@ -137,9 +137,26 @@ export class CourseSyncService {
|
|
|
137
137
|
async ensureSynced(courseId: string, forceEnabled?: boolean): Promise<void> {
|
|
138
138
|
const existing = this.entries.get(courseId);
|
|
139
139
|
|
|
140
|
-
// Already synced
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// Already synced — but check if the remote DB has been recreated
|
|
141
|
+
// (e.g., after a dev reseed). The seed script writes a `db-epoch`
|
|
142
|
+
// doc; if the remote epoch differs from our local copy, the local
|
|
143
|
+
// replica is stale and must be destroyed before re-syncing.
|
|
144
|
+
if (existing?.status.state === 'ready' && existing.localDB) {
|
|
145
|
+
const stale = await this.isLocalEpochStale(courseId, existing.localDB);
|
|
146
|
+
if (!stale) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
logger.info(
|
|
150
|
+
`[CourseSyncService] Remote DB epoch changed for course ${courseId} — destroying stale local replica`
|
|
151
|
+
);
|
|
152
|
+
try {
|
|
153
|
+
await existing.localDB.destroy();
|
|
154
|
+
} catch {
|
|
155
|
+
// Ignore cleanup errors
|
|
156
|
+
}
|
|
157
|
+
existing.localDB = null;
|
|
158
|
+
existing.readyPromise = null;
|
|
159
|
+
// Fall through to start a fresh sync
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
// Already disabled
|
|
@@ -224,7 +241,23 @@ export class CourseSyncService {
|
|
|
224
241
|
// Step 2: Create local PouchDB and replicate
|
|
225
242
|
entry.status = { state: 'syncing' };
|
|
226
243
|
const localDBName = this.localDBName(courseId);
|
|
227
|
-
|
|
244
|
+
let localDB = new pouch(localDBName);
|
|
245
|
+
|
|
246
|
+
// Check for stale local replica before replicating. If the remote DB
|
|
247
|
+
// was wiped and recreated (e.g., `yarn db:seed`), the local PouchDB
|
|
248
|
+
// has documents at rev 1-oldHash while the remote has 1-newHash for
|
|
249
|
+
// the same _ids. PouchDB replication treats this as a conflict rather
|
|
250
|
+
// than an update, and the stale revision can win — causing partial or
|
|
251
|
+
// incorrect data. Destroying the stale local DB avoids this entirely.
|
|
252
|
+
const stale = await this.isLocalEpochStale(courseId, localDB);
|
|
253
|
+
if (stale) {
|
|
254
|
+
logger.info(
|
|
255
|
+
`[CourseSyncService] Stale local DB detected for course ${courseId} — destroying before sync`
|
|
256
|
+
);
|
|
257
|
+
await localDB.destroy();
|
|
258
|
+
localDB = new pouch(localDBName);
|
|
259
|
+
}
|
|
260
|
+
|
|
228
261
|
entry.localDB = localDB;
|
|
229
262
|
|
|
230
263
|
const remoteDB = this.getRemoteDB(courseId);
|
|
@@ -340,6 +373,41 @@ export class CourseSyncService {
|
|
|
340
373
|
}
|
|
341
374
|
}
|
|
342
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Check whether the local replica's `db-epoch` doc matches the remote.
|
|
378
|
+
*
|
|
379
|
+
* The seed script (and optionally upload-cards) writes a `db-epoch`
|
|
380
|
+
* document with a numeric timestamp. If the remote epoch differs from
|
|
381
|
+
* the local copy, the remote DB was recreated (e.g., `yarn db:seed`)
|
|
382
|
+
* and the local PouchDB is stale.
|
|
383
|
+
*
|
|
384
|
+
* Returns `true` if stale (epoch mismatch or remote has epoch but local
|
|
385
|
+
* doesn't). Returns `false` (not stale) if epochs match, or if the
|
|
386
|
+
* remote doesn't have an epoch doc at all (backwards compat).
|
|
387
|
+
*/
|
|
388
|
+
private async isLocalEpochStale(
|
|
389
|
+
courseId: string,
|
|
390
|
+
localDB: PouchDB.Database
|
|
391
|
+
): Promise<boolean> {
|
|
392
|
+
try {
|
|
393
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
394
|
+
const remoteEpoch = await remoteDB.get<{ epoch: number }>('db-epoch');
|
|
395
|
+
|
|
396
|
+
let localEpoch: { epoch: number } | null = null;
|
|
397
|
+
try {
|
|
398
|
+
localEpoch = await localDB.get<{ epoch: number }>('db-epoch');
|
|
399
|
+
} catch {
|
|
400
|
+
// Local doesn't have the epoch doc — stale
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return remoteEpoch.epoch !== localEpoch.epoch;
|
|
405
|
+
} catch {
|
|
406
|
+
// Remote doesn't have db-epoch — no epoch tracking, not stale
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
343
411
|
/**
|
|
344
412
|
* Get a remote PouchDB handle for a course.
|
|
345
413
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { StudyContentSource } from '@db/core/interfaces/contentSource';
|
|
2
2
|
import { WeightedCard } from '@db/core/navigators';
|
|
3
|
+
import type { GeneratorResult } from '@db/core/navigators/generators/types';
|
|
3
4
|
import { ClassroomConfig } from '@vue-skuilder/common';
|
|
4
5
|
import { ENV } from '@db/factory';
|
|
5
6
|
import { logger } from '@db/util/logger';
|
|
@@ -126,7 +127,7 @@ export class StudentClassroomDB
|
|
|
126
127
|
* @param limit - Maximum number of cards to return
|
|
127
128
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
128
129
|
*/
|
|
129
|
-
public async getWeightedCards(limit: number): Promise<
|
|
130
|
+
public async getWeightedCards(limit: number): Promise<GeneratorResult> {
|
|
130
131
|
const weighted: WeightedCard[] = [];
|
|
131
132
|
|
|
132
133
|
// Get pending reviews for this classroom
|
|
@@ -167,7 +168,7 @@ export class StudentClassroomDB
|
|
|
167
168
|
if (content.type === 'course') {
|
|
168
169
|
// Get weighted cards from the course directly
|
|
169
170
|
const db = new CourseDB(content.courseID, async () => this._user);
|
|
170
|
-
const courseCards = await db.getWeightedCards(limit);
|
|
171
|
+
const { cards: courseCards } = await db.getWeightedCards(limit);
|
|
171
172
|
for (const card of courseCards) {
|
|
172
173
|
if (!activeCardIds.has(card.cardId)) {
|
|
173
174
|
weighted.push({
|
|
@@ -235,7 +236,7 @@ export class StudentClassroomDB
|
|
|
235
236
|
);
|
|
236
237
|
|
|
237
238
|
// Sort by score descending and limit
|
|
238
|
-
return weighted.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
239
|
+
return { cards: weighted.sort((a, b) => b.score - a.score).slice(0, limit) };
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CourseDBInterface, CourseInfo, CoursesDBInterface, UserDBInterface } from '@db/core';
|
|
2
|
+
import type { GeneratorResult, ReplanHints } from '@db/core/navigators/generators/types';
|
|
2
3
|
import {
|
|
3
4
|
CourseConfig,
|
|
4
5
|
CourseElo,
|
|
@@ -28,7 +29,7 @@ import { DataLayerResult } from '@db/core/types/db';
|
|
|
28
29
|
import { PouchError } from './types';
|
|
29
30
|
import CourseLookup from './courseLookupDB';
|
|
30
31
|
import { ContentNavigationStrategyData } from '@db/core/types/contentNavigationStrategy';
|
|
31
|
-
import { ContentNavigator, Navigators
|
|
32
|
+
import { ContentNavigator, Navigators } from '@db/core/navigators';
|
|
32
33
|
import { PipelineAssembler } from '@db/core/navigators/PipelineAssembler';
|
|
33
34
|
import { createDefaultPipeline } from '@db/core/navigators/defaults';
|
|
34
35
|
|
|
@@ -650,13 +651,13 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
650
651
|
* @param limit - Maximum number of cards to return
|
|
651
652
|
* @returns Cards sorted by score descending
|
|
652
653
|
*/
|
|
653
|
-
private _pendingHints:
|
|
654
|
+
private _pendingHints: ReplanHints | null = null;
|
|
654
655
|
|
|
655
|
-
public setEphemeralHints(hints:
|
|
656
|
+
public setEphemeralHints(hints: ReplanHints): void {
|
|
656
657
|
this._pendingHints = hints;
|
|
657
658
|
}
|
|
658
659
|
|
|
659
|
-
public async getWeightedCards(limit: number): Promise<
|
|
660
|
+
public async getWeightedCards(limit: number): Promise<GeneratorResult> {
|
|
660
661
|
const u = await this._getCurrentUser();
|
|
661
662
|
|
|
662
663
|
try {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CourseInfo,
|
|
7
7
|
StudySessionItem,
|
|
8
8
|
} from '../../core/interfaces';
|
|
9
|
+
import type { GeneratorResult, ReplanHints } from '../../core/navigators/generators/types';
|
|
9
10
|
import { StaticDataUnpacker } from './StaticDataUnpacker';
|
|
10
11
|
import { StaticCourseManifest } from '../../util/packer/types';
|
|
11
12
|
import { CourseConfig, CourseElo, DataShape, Status } from '@vue-skuilder/common';
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
import { DataLayerResult } from '../../core/types/db';
|
|
21
22
|
import { ContentNavigationStrategyData } from '../../core/types/contentNavigationStrategy';
|
|
22
23
|
|
|
23
|
-
import { ContentNavigator
|
|
24
|
+
import { ContentNavigator } from '../../core/navigators';
|
|
24
25
|
import { logger } from '../../util/logger';
|
|
25
26
|
import { createDefaultPipeline } from '@db/core/navigators/defaults';
|
|
26
27
|
import { PipelineAssembler } from '@db/core/navigators/PipelineAssembler';
|
|
@@ -443,13 +444,13 @@ export class StaticCourseDB implements CourseDBInterface {
|
|
|
443
444
|
|
|
444
445
|
// Study Content Source implementation
|
|
445
446
|
|
|
446
|
-
private _pendingHints:
|
|
447
|
+
private _pendingHints: ReplanHints | null = null;
|
|
447
448
|
|
|
448
|
-
setEphemeralHints(hints:
|
|
449
|
+
setEphemeralHints(hints: ReplanHints): void {
|
|
449
450
|
this._pendingHints = hints;
|
|
450
451
|
}
|
|
451
452
|
|
|
452
|
-
async getWeightedCards(limit: number): Promise<
|
|
453
|
+
async getWeightedCards(limit: number): Promise<GeneratorResult> {
|
|
453
454
|
try {
|
|
454
455
|
const navigator = await this.createNavigator(this.userDB);
|
|
455
456
|
// Forward any pending hints to the Pipeline
|
|
@@ -16,10 +16,15 @@ import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord } from '
|
|
|
16
16
|
import { recordUserOutcome } from '@db/core/orchestration/recording';
|
|
17
17
|
import { Loggable } from '@db/util';
|
|
18
18
|
import { getCardOrigin } from '@db/core/navigators';
|
|
19
|
+
import { ReplanHints } from '@db/core/navigators/generators/types';
|
|
19
20
|
import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
20
21
|
import { captureMixerRun } from './MixerDebugger';
|
|
21
22
|
import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
|
|
22
23
|
|
|
24
|
+
// ReplanHints is defined in generators/types to avoid circular dependencies.
|
|
25
|
+
// Re-exported here for backward compatibility.
|
|
26
|
+
export type { ReplanHints } from '@db/core/navigators/generators/types';
|
|
27
|
+
|
|
23
28
|
/**
|
|
24
29
|
* Options for requesting a mid-session replan.
|
|
25
30
|
*
|
|
@@ -29,7 +34,7 @@ import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessio
|
|
|
29
34
|
*/
|
|
30
35
|
export interface ReplanOptions {
|
|
31
36
|
/** Scoring hints forwarded to the pipeline (boost/exclude/require). */
|
|
32
|
-
hints?:
|
|
37
|
+
hints?: ReplanHints;
|
|
33
38
|
/**
|
|
34
39
|
* Maximum number of new cards to return from the pipeline.
|
|
35
40
|
* Default: 20 (the standard session batch size).
|
|
@@ -41,6 +46,13 @@ export interface ReplanOptions {
|
|
|
41
46
|
* - `'merge'`: insert new cards at the front, keeping existing cards.
|
|
42
47
|
*/
|
|
43
48
|
mode?: 'replace' | 'merge';
|
|
49
|
+
/**
|
|
50
|
+
* Guarantee that at least this many cards will be served after the
|
|
51
|
+
* replan, even if the session timer has expired. Prevents intro cards
|
|
52
|
+
* from surfacing at the end of a session with zero follow-up exercise.
|
|
53
|
+
* Decremented on each card draw while active.
|
|
54
|
+
*/
|
|
55
|
+
minFollowUpCards?: number;
|
|
44
56
|
/**
|
|
45
57
|
* Human-readable label for debugging / provenance.
|
|
46
58
|
* Appears in console logs and in card provenance entries created
|
|
@@ -156,12 +168,22 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
156
168
|
*/
|
|
157
169
|
private _depletionReplanAttempted: boolean = false;
|
|
158
170
|
|
|
171
|
+
/**
|
|
172
|
+
* When > 0, the session timer cannot end the session. Decremented on
|
|
173
|
+
* each nextCard() draw. Set by replans that include `minFollowUpCards`.
|
|
174
|
+
*/
|
|
175
|
+
private _minCardsGuarantee: number = 0;
|
|
176
|
+
|
|
159
177
|
private startTime: Date;
|
|
160
178
|
private endTime: Date;
|
|
161
179
|
private _secondsRemaining: number;
|
|
162
180
|
public get secondsRemaining(): number {
|
|
163
181
|
return this._secondsRemaining;
|
|
164
182
|
}
|
|
183
|
+
/** True when a card guarantee is active, preventing timer-based session end. */
|
|
184
|
+
public get hasCardGuarantee(): boolean {
|
|
185
|
+
return this._minCardsGuarantee > 0;
|
|
186
|
+
}
|
|
165
187
|
public get report(): string {
|
|
166
188
|
const reviewCount = this.reviewQ.dequeueCount;
|
|
167
189
|
const newCount = this.newQ.dequeueCount;
|
|
@@ -316,7 +338,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
316
338
|
* Typical trigger: application-level code (e.g. after a GPC intro completion)
|
|
317
339
|
* calls this to ensure newly-unlocked content appears in the session.
|
|
318
340
|
*/
|
|
319
|
-
public async requestReplan(options?: ReplanOptions |
|
|
341
|
+
public async requestReplan(options?: ReplanOptions | ReplanHints): Promise<void> {
|
|
320
342
|
// Normalise: bare hints object (legacy callers) → ReplanOptions wrapper
|
|
321
343
|
const opts = this.normalizeReplanOptions(options);
|
|
322
344
|
|
|
@@ -331,6 +353,19 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
331
353
|
return this._replanPromise;
|
|
332
354
|
}
|
|
333
355
|
|
|
356
|
+
// Auto-exclude the currently-displayed card so the replan doesn't
|
|
357
|
+
// surface it again (avoids showing the same card twice in a row).
|
|
358
|
+
if (this._currentCard?.item.cardID) {
|
|
359
|
+
const currentId = this._currentCard.item.cardID;
|
|
360
|
+
if (!opts.hints) opts.hints = {};
|
|
361
|
+
const hints = opts.hints;
|
|
362
|
+
const excludeCards = hints.excludeCards ?? [];
|
|
363
|
+
if (!excludeCards.includes(currentId)) {
|
|
364
|
+
excludeCards.push(currentId);
|
|
365
|
+
}
|
|
366
|
+
hints.excludeCards = excludeCards;
|
|
367
|
+
}
|
|
368
|
+
|
|
334
369
|
// Forward hints to all sources (CourseDB stashes them, Pipeline consumes them)
|
|
335
370
|
if (opts.hints) {
|
|
336
371
|
// Thread label into hints so Pipeline can attach it to provenance
|
|
@@ -348,6 +383,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
348
383
|
` (limit: ${opts.limit ?? 'default'}, mode: ${opts.mode ?? 'replace'}` +
|
|
349
384
|
`${opts.hints ? ', with hints' : ''})`
|
|
350
385
|
);
|
|
386
|
+
// Update card guarantee if requested
|
|
387
|
+
if (opts.minFollowUpCards !== undefined && opts.minFollowUpCards > 0) {
|
|
388
|
+
this._minCardsGuarantee = Math.max(this._minCardsGuarantee, opts.minFollowUpCards);
|
|
389
|
+
this.log(`[Replan] Card guarantee set to ${this._minCardsGuarantee}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
351
392
|
this._replanPromise = this._executeReplan(opts);
|
|
352
393
|
|
|
353
394
|
try {
|
|
@@ -364,19 +405,19 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
364
405
|
* the presence of ReplanOptions-specific keys.
|
|
365
406
|
*/
|
|
366
407
|
private normalizeReplanOptions(
|
|
367
|
-
input?: ReplanOptions |
|
|
408
|
+
input?: ReplanOptions | ReplanHints
|
|
368
409
|
): ReplanOptions {
|
|
369
410
|
if (!input) return {};
|
|
370
411
|
|
|
371
412
|
// If the input has any ReplanOptions-specific key, treat it as ReplanOptions
|
|
372
|
-
const replanKeys = ['hints', 'limit', 'mode', 'label'];
|
|
413
|
+
const replanKeys = ['hints', 'limit', 'mode', 'label', 'minFollowUpCards'];
|
|
373
414
|
const inputKeys = Object.keys(input);
|
|
374
415
|
if (inputKeys.some((k) => replanKeys.includes(k))) {
|
|
375
416
|
return input as ReplanOptions;
|
|
376
417
|
}
|
|
377
418
|
|
|
378
419
|
// Otherwise treat as legacy bare-hints object
|
|
379
|
-
return { hints: input as
|
|
420
|
+
return { hints: input as ReplanHints };
|
|
380
421
|
}
|
|
381
422
|
|
|
382
423
|
/** Minimum well-indicated cards before an additive retry is attempted */
|
|
@@ -509,6 +550,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
509
550
|
inProgress: this._replanPromise !== null,
|
|
510
551
|
suppressQualityReplan: this._suppressQualityReplan,
|
|
511
552
|
defaultBatchLimit: this._defaultBatchLimit,
|
|
553
|
+
minCardsGuarantee: this._minCardsGuarantee,
|
|
512
554
|
},
|
|
513
555
|
};
|
|
514
556
|
}
|
|
@@ -549,7 +591,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
549
591
|
const source = this.sources[i];
|
|
550
592
|
try {
|
|
551
593
|
// Fetch weighted cards for mixing
|
|
552
|
-
const weighted = await source.getWeightedCards!(limit);
|
|
594
|
+
const weighted = (await source.getWeightedCards!(limit)).cards;
|
|
553
595
|
|
|
554
596
|
batches.push({
|
|
555
597
|
sourceIndex: i,
|
|
@@ -709,13 +751,13 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
709
751
|
return null;
|
|
710
752
|
}
|
|
711
753
|
|
|
712
|
-
if (this._secondsRemaining < 2 && this.failedQ.length === 0) {
|
|
754
|
+
if (this._secondsRemaining < 2 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
713
755
|
// session is over!
|
|
714
756
|
return null;
|
|
715
757
|
}
|
|
716
758
|
|
|
717
|
-
// If timer expired, only return failed cards
|
|
718
|
-
if (this._secondsRemaining <= 0) {
|
|
759
|
+
// If timer expired, only return failed cards (unless card guarantee active)
|
|
760
|
+
if (this._secondsRemaining <= 0 && this._minCardsGuarantee <= 0) {
|
|
719
761
|
if (this.failedQ.length > 0) {
|
|
720
762
|
return this.failedQ.peek(0);
|
|
721
763
|
} else {
|
|
@@ -777,6 +819,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
777
819
|
// dismiss (or sort to failedQ) the current card
|
|
778
820
|
this.dismissCurrentCard(action);
|
|
779
821
|
|
|
822
|
+
// Decrement card guarantee counter
|
|
823
|
+
if (this._minCardsGuarantee > 0) {
|
|
824
|
+
this._minCardsGuarantee--;
|
|
825
|
+
this.log(`[CardGuarantee] ${this._minCardsGuarantee} guaranteed cards remaining`);
|
|
826
|
+
}
|
|
827
|
+
|
|
780
828
|
// If a replan is in flight, wait for it to complete before drawing.
|
|
781
829
|
// This ensures the user sees cards scored against their latest state
|
|
782
830
|
// (e.g. after a GPC intro unlocked new content).
|
|
@@ -853,7 +901,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
853
901
|
void this.requestReplan();
|
|
854
902
|
}
|
|
855
903
|
|
|
856
|
-
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
904
|
+
if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
857
905
|
this._currentCard = null;
|
|
858
906
|
endSessionTracking();
|
|
859
907
|
return null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { StudyContentSource } from '@db/core/interfaces/contentSource';
|
|
2
2
|
import { WeightedCard } from '@db/core/navigators';
|
|
3
|
+
import type { GeneratorResult } from '@db/core/navigators/generators/types';
|
|
3
4
|
import { UserDBInterface } from '@db/core';
|
|
4
5
|
import { TagFilter, hasActiveFilter } from '@vue-skuilder/common';
|
|
5
6
|
import { getTag } from '../impl/couch/courseDB';
|
|
@@ -117,10 +118,10 @@ export class TagFilteredContentSource implements StudyContentSource {
|
|
|
117
118
|
* @param limit - Maximum number of cards to return
|
|
118
119
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
119
120
|
*/
|
|
120
|
-
public async getWeightedCards(limit: number): Promise<
|
|
121
|
+
public async getWeightedCards(limit: number): Promise<GeneratorResult> {
|
|
121
122
|
if (!hasActiveFilter(this.filter)) {
|
|
122
123
|
logger.warn('[TagFilteredContentSource] getWeightedCards called with no active filter');
|
|
123
|
-
return [];
|
|
124
|
+
return { cards: [] };
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
@@ -185,7 +186,7 @@ export class TagFilteredContentSource implements StudyContentSource {
|
|
|
185
186
|
}));
|
|
186
187
|
|
|
187
188
|
// Reviews first, then new cards; respect limit
|
|
188
|
-
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
189
|
+
return { cards: [...reviewWeighted, ...newCardWeighted].slice(0, limit) };
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
/**
|
|
@@ -111,10 +111,29 @@ export class EloService {
|
|
|
111
111
|
const userElo = toCourseElo(
|
|
112
112
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo
|
|
113
113
|
);
|
|
114
|
-
|
|
114
|
+
|
|
115
|
+
const [cardEloResults, cardTagsMap] = await Promise.all([
|
|
116
|
+
courseDB.getCardEloData([currentCard.card.card_id]),
|
|
117
|
+
courseDB.getAppliedTagsBatch([card_id]),
|
|
118
|
+
]);
|
|
119
|
+
const cardElo = cardEloResults[0];
|
|
120
|
+
|
|
121
|
+
// Enrich TaggedPerformance with card-level tags not explicitly graded by
|
|
122
|
+
// the question's evaluate(). Category tags (concept:*, ui:*, etc.) are not
|
|
123
|
+
// emitted by individual question types; applying the global score as a proxy
|
|
124
|
+
// keeps hierarchy filter ELO thresholds functional without overriding any
|
|
125
|
+
// fine-grained per-GPC scores the question already provided.
|
|
126
|
+
const cardTags = cardTagsMap.get(card_id) ?? [];
|
|
127
|
+
const enriched: TaggedPerformance = { ...taggedPerformance };
|
|
128
|
+
const globalScore = taggedPerformance._global;
|
|
129
|
+
for (const tag of cardTags) {
|
|
130
|
+
if (!(tag in enriched)) {
|
|
131
|
+
enriched[tag] = globalScore;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
115
134
|
|
|
116
135
|
if (cardElo && userElo) {
|
|
117
|
-
const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo,
|
|
136
|
+
const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo, enriched);
|
|
118
137
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo = eloUpdate.userElo;
|
|
119
138
|
|
|
120
139
|
const results = await Promise.allSettled([
|
|
@@ -131,7 +150,7 @@ export class EloService {
|
|
|
131
150
|
const card = (results[1] as PromiseFulfilledResult<any>).value;
|
|
132
151
|
|
|
133
152
|
if (user.ok && card && card.ok) {
|
|
134
|
-
const tagCount = Object.keys(
|
|
153
|
+
const tagCount = Object.keys(enriched).length - 1; // exclude _global
|
|
135
154
|
logger.info(
|
|
136
155
|
`[EloService] Updated ELOS (per-tag, ${tagCount} tags):
|
|
137
156
|
\tUser: ${JSON.stringify(eloUpdate.userElo)})
|
|
@@ -181,6 +181,13 @@ export class ResponseProcessor {
|
|
|
181
181
|
// Update ELO ratings
|
|
182
182
|
if (taggedPerformance) {
|
|
183
183
|
// Per-tag ELO update
|
|
184
|
+
const tagKeys = Object.keys(taggedPerformance).filter((k) => k !== '_global');
|
|
185
|
+
const nullTags = tagKeys.filter((k) => taggedPerformance[k] === null);
|
|
186
|
+
const scoredTags = tagKeys.filter((k) => taggedPerformance[k] !== null);
|
|
187
|
+
logger.info(
|
|
188
|
+
`[ResponseProcessor] per-tag ELO update for ${cardId}: scored=[${scoredTags.join(', ')}] count-only=[${nullTags.join(', ')}]`
|
|
189
|
+
);
|
|
190
|
+
|
|
184
191
|
void this.eloService.updateUserAndCardEloPerTag(
|
|
185
192
|
taggedPerformance,
|
|
186
193
|
courseId,
|
|
@@ -188,9 +195,6 @@ export class ResponseProcessor {
|
|
|
188
195
|
courseRegistrationDoc,
|
|
189
196
|
currentCard
|
|
190
197
|
);
|
|
191
|
-
logger.info(
|
|
192
|
-
`[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
|
|
193
|
-
);
|
|
194
198
|
} else {
|
|
195
199
|
// Standard single-score ELO update (backward compatible)
|
|
196
200
|
const userScore = 0.5 + globalScore / 2;
|