@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
package/src/study/SourceMixer.ts
CHANGED
|
@@ -30,16 +30,16 @@ export interface SourceMixer {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
* taking the top-N cards by score from each.
|
|
33
|
+
* Quota-based mixer with interleaved output.
|
|
35
34
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* source.
|
|
35
|
+
* Allocates equal representation to each source (top-N by score), then
|
|
36
|
+
* interleaves the results by dealing from a randomly-shuffled source order.
|
|
37
|
+
* Within each source, cards are dealt in score-descending order.
|
|
39
38
|
*
|
|
40
|
-
* This
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
* This ensures that cards from different courses are spread throughout the
|
|
40
|
+
* queue rather than clustered by score bands, which matters because
|
|
41
|
+
* SessionController consumes queues front-to-back and sessions often end
|
|
42
|
+
* before reaching the tail.
|
|
43
43
|
*/
|
|
44
44
|
export class QuotaRoundRobinMixer implements SourceMixer {
|
|
45
45
|
mix(batches: SourceBatch[], limit: number): WeightedCard[] {
|
|
@@ -48,18 +48,37 @@ export class QuotaRoundRobinMixer implements SourceMixer {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const quotaPerSource = Math.ceil(limit / batches.length);
|
|
51
|
-
const mixed: WeightedCard[] = [];
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
// Build per-source stacks sorted by score descending, capped at quota
|
|
53
|
+
const sourceStacks: WeightedCard[][] = batches.map((batch) => {
|
|
54
|
+
return [...batch.weighted].sort((a, b) => b.score - a.score).slice(0, quotaPerSource);
|
|
55
|
+
});
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
// Shuffle the source ordering so no course is systematically first
|
|
58
|
+
for (let i = sourceStacks.length - 1; i > 0; i--) {
|
|
59
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
60
|
+
[sourceStacks[i], sourceStacks[j]] = [sourceStacks[j], sourceStacks[i]];
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
//
|
|
63
|
-
|
|
63
|
+
// Interleave: deal one card from each source in rotation
|
|
64
|
+
const result: WeightedCard[] = [];
|
|
65
|
+
let exhausted = 0;
|
|
66
|
+
const cursors = new Array(sourceStacks.length).fill(0);
|
|
67
|
+
|
|
68
|
+
while (result.length < limit && exhausted < sourceStacks.length) {
|
|
69
|
+
exhausted = 0;
|
|
70
|
+
for (let s = 0; s < sourceStacks.length; s++) {
|
|
71
|
+
if (result.length >= limit) break;
|
|
72
|
+
|
|
73
|
+
if (cursors[s] < sourceStacks[s].length) {
|
|
74
|
+
result.push(sourceStacks[s][cursors[s]]);
|
|
75
|
+
cursors[s]++;
|
|
76
|
+
} else {
|
|
77
|
+
exhausted++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
64
83
|
}
|
|
65
84
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Session Scheduling: Observed Deficiencies & Future Work
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Observed during debugging of multi-course study sessions (Feb 2026). A 5-minute
|
|
6
|
+
session with 6 courses was scheduling ~101 cards (all available reviews) despite
|
|
7
|
+
only having time for ~20. Cards were globally sorted by score, causing
|
|
8
|
+
lower-scoring courses to be systematically unreached before session timeout.
|
|
9
|
+
|
|
10
|
+
Debuggers added: `window.skuilder.mixer`, `window.skuilder.session`.
|
|
11
|
+
|
|
12
|
+
## Completed
|
|
13
|
+
|
|
14
|
+
- **Mixer interleaving**: `QuotaRoundRobinMixer` now interleaves output by
|
|
15
|
+
dealing from a randomly-shuffled source order instead of global score sort.
|
|
16
|
+
This spreads courses throughout the queue so time-cutoff doesn't
|
|
17
|
+
systematically exclude lower-scoring sources.
|
|
18
|
+
|
|
19
|
+
- **MixerDebugger**: Captures cross-source mixing decisions. Shows input
|
|
20
|
+
batches, score distributions, selection rates, source balance analysis.
|
|
21
|
+
|
|
22
|
+
- **SessionDebugger**: Tracks runtime card presentation order, course
|
|
23
|
+
interleaving patterns, clustering detection.
|
|
24
|
+
|
|
25
|
+
## Phase A: Right-Size Initial Load
|
|
26
|
+
|
|
27
|
+
**Problem**: `limit = 20` per source is hardcoded with no time-budget awareness.
|
|
28
|
+
For 6 sources that's up to 120 candidates for a 5-minute session.
|
|
29
|
+
|
|
30
|
+
**Approach**: Compute a realistic `targetCards` from session time and per-card
|
|
31
|
+
time estimates:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
targetCards = sessionTimeSeconds / avgCardTimeSeconds
|
|
35
|
+
quotaPerSource = ceil(targetCards / numSources)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Data available**: `CardRecord.timeSpent` (ms) exists on every historical
|
|
39
|
+
record. `CardHistory.records[]` has full per-card history. Could compute:
|
|
40
|
+
- Global user average (blunt but simple)
|
|
41
|
+
- Per-origin averages (reviews typically faster than new cards)
|
|
42
|
+
- Per-course averages (some courses have harder cards)
|
|
43
|
+
|
|
44
|
+
**Also**: `estimateReviewTime()` currently uses a flat `5 * reviewQ.length`.
|
|
45
|
+
Should use actual per-card time data, at minimum per-origin averages.
|
|
46
|
+
|
|
47
|
+
**Considerations**:
|
|
48
|
+
- Don't under-schedule — running out of cards mid-session is worse than
|
|
49
|
+
over-scheduling slightly
|
|
50
|
+
- Historical data may not exist for new users; need a reasonable default
|
|
51
|
+
- New cards take longer than reviews; weight accordingly
|
|
52
|
+
|
|
53
|
+
## Phase B: Interleaving in _selectNextItemToHydrate
|
|
54
|
+
|
|
55
|
+
**Problem**: Even with interleaved mixer output, the queue split into
|
|
56
|
+
`reviewQ`/`newQ` can re-cluster by course if one course dominates reviews.
|
|
57
|
+
`_selectNextItemToHydrate` always takes `peek(0)` — course-blind.
|
|
58
|
+
|
|
59
|
+
**Approach**: After picking which queue, scan forward with small lookahead
|
|
60
|
+
to prefer a different course than the last presented card.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
1. Pick queue (existing probability logic)
|
|
64
|
+
2. If peek(0).courseID === lastPresentedCourseID:
|
|
65
|
+
Scan peek(1)..peek(LOOKAHEAD) for different course
|
|
66
|
+
3. If found: dequeue from that position
|
|
67
|
+
4. If not found within lookahead: take peek(0) anyway
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Requires**: `dequeueAt(index)` method on `ItemQueue` (currently only
|
|
71
|
+
dequeues from front).
|
|
72
|
+
|
|
73
|
+
**Trade-offs**:
|
|
74
|
+
- Lookahead window size (3-5 is probably right)
|
|
75
|
+
- Don't starve a dominant course forever — bounded lookahead handles this
|
|
76
|
+
- Interacts with hydration: the card at peek(3) may not be pre-hydrated.
|
|
77
|
+
Hydration window is currently 2 per queue. May need to increase slightly
|
|
78
|
+
or accept occasional hydration waits.
|
|
79
|
+
|
|
80
|
+
## Phase C: Rolling Refill (Mid-Session Recalibration)
|
|
81
|
+
|
|
82
|
+
**Problem**: All scheduling decisions are made at session start. No ability to
|
|
83
|
+
adapt to actual session pace, user performance, or course balance during the
|
|
84
|
+
session.
|
|
85
|
+
|
|
86
|
+
**Current state**: Queues loaded once in `prepareSession()`. Only mutation
|
|
87
|
+
during session is consumption + failure re-queuing. Hydration pre-caches 2 per
|
|
88
|
+
queue (6 total) — so the vast majority of queued items are just waiting.
|
|
89
|
+
|
|
90
|
+
**Approach**: Small initial load + async refill when queues run low.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
1. Initial load: ~8-10 cards per source (enough for first few minutes)
|
|
94
|
+
2. Monitor queue depth after each card presentation
|
|
95
|
+
3. When queue drops below threshold (3-4 cards):
|
|
96
|
+
→ Trigger async refill from source(s)
|
|
97
|
+
→ Refill decisions factor in:
|
|
98
|
+
- Actual pace (cards completed / time elapsed)
|
|
99
|
+
- Course balance (which courses are underrepresented)
|
|
100
|
+
- User performance (more failures = slower pace)
|
|
101
|
+
4. Refill populates queue; hydration service picks up new items naturally
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Key constraint**: Next card must always be pre-cached and ready without a DB
|
|
105
|
+
lookup. This is satisfied because refills happen at queue level (async,
|
|
106
|
+
background), and the hydration service already runs after each card.
|
|
107
|
+
|
|
108
|
+
**Architecture considerations**:
|
|
109
|
+
- `StudyContentSource.getWeightedCards()` would need to be callable
|
|
110
|
+
mid-session (currently only called in `prepareSession`)
|
|
111
|
+
- Need to track which cards were already fetched to avoid duplicates
|
|
112
|
+
(ItemQueue.seenCardIds partially handles this)
|
|
113
|
+
- Refill trigger should be non-blocking — fire and forget, let hydration
|
|
114
|
+
service handle the rest
|
|
115
|
+
- Consider whether sources need a "cursor" or "offset" to avoid re-fetching
|
|
116
|
+
the same top-N cards
|
|
117
|
+
|
|
118
|
+
## Related Observations
|
|
119
|
+
|
|
120
|
+
- **Score normalization**: Different courses/navigators produce scores on
|
|
121
|
+
different effective ranges (e.g., 0.36-0.49 vs 0.70-0.98). The interleaving
|
|
122
|
+
fix addresses the presentation problem, but a MinMaxNormalizingMixer or
|
|
123
|
+
similar could address the selection problem for cases where quota-based
|
|
124
|
+
selection isn't desired.
|
|
125
|
+
|
|
126
|
+
- **No user-level pace tracking**: The system has per-card `timeSpent` data but
|
|
127
|
+
no aggregated user pace metric. A lightweight running average (e.g.,
|
|
128
|
+
exponential moving average of last 20 cards) would be useful for both
|
|
129
|
+
Phase A (initial load sizing) and Phase C (refill decisions).
|
|
130
|
+
|
|
131
|
+
- **`estimateCleanupTime` scope**: Currently only uses current-session records
|
|
132
|
+
for failed cards. Could also use historical `CardHistory` for cards the user
|
|
133
|
+
has seen before, giving better estimates for review failures.
|
package/src/study/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
adjustCourseScores,
|
|
3
|
+
adjustCourseScoresPerTag,
|
|
4
|
+
toCourseElo,
|
|
5
|
+
TaggedPerformance,
|
|
6
|
+
} from '@vue-skuilder/common';
|
|
2
7
|
import { DataLayerProvider, UserDBInterface, CourseRegistrationDoc } from '@db/core';
|
|
3
8
|
import { StudySessionRecord } from '../SessionController';
|
|
4
9
|
import { logger } from '@db/util/logger';
|
|
@@ -19,7 +24,7 @@ export class EloService {
|
|
|
19
24
|
* Updates both user and card ELO ratings based on user performance.
|
|
20
25
|
* @param userScore Score between 0-1 representing user performance
|
|
21
26
|
* @param course_id Course identifier
|
|
22
|
-
* @param card_id Card identifier
|
|
27
|
+
* @param card_id Card identifier
|
|
23
28
|
* @param userCourseRegDoc User's course registration document (will be mutated)
|
|
24
29
|
* @param currentCard Current card session record
|
|
25
30
|
* @param k Optional K-factor for ELO calculation
|
|
@@ -36,7 +41,9 @@ export class EloService {
|
|
|
36
41
|
logger.warn(`k value interpretation not currently implemented`);
|
|
37
42
|
}
|
|
38
43
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
39
|
-
const userElo = toCourseElo(
|
|
44
|
+
const userElo = toCourseElo(
|
|
45
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo
|
|
46
|
+
);
|
|
40
47
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
41
48
|
|
|
42
49
|
if (cardElo && userElo) {
|
|
@@ -82,4 +89,72 @@ export class EloService {
|
|
|
82
89
|
}
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
|
-
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Updates both user and card ELO ratings with per-tag granularity.
|
|
95
|
+
* Tags in taggedPerformance but not on card will be created dynamically.
|
|
96
|
+
*
|
|
97
|
+
* @param taggedPerformance Performance object with _global and per-tag scores
|
|
98
|
+
* @param course_id Course identifier
|
|
99
|
+
* @param card_id Card identifier
|
|
100
|
+
* @param userCourseRegDoc User's course registration document (will be mutated)
|
|
101
|
+
* @param currentCard Current card session record
|
|
102
|
+
*/
|
|
103
|
+
public async updateUserAndCardEloPerTag(
|
|
104
|
+
taggedPerformance: TaggedPerformance,
|
|
105
|
+
course_id: string,
|
|
106
|
+
card_id: string,
|
|
107
|
+
userCourseRegDoc: CourseRegistrationDoc,
|
|
108
|
+
currentCard: StudySessionRecord
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
111
|
+
const userElo = toCourseElo(
|
|
112
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo
|
|
113
|
+
);
|
|
114
|
+
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
115
|
+
|
|
116
|
+
if (cardElo && userElo) {
|
|
117
|
+
const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo, taggedPerformance);
|
|
118
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo = eloUpdate.userElo;
|
|
119
|
+
|
|
120
|
+
const results = await Promise.allSettled([
|
|
121
|
+
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
122
|
+
courseDB.updateCardElo(card_id, eloUpdate.cardElo),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
// Check the results of each operation
|
|
126
|
+
const userEloStatus = results[0].status === 'fulfilled';
|
|
127
|
+
const cardEloStatus = results[1].status === 'fulfilled';
|
|
128
|
+
|
|
129
|
+
if (userEloStatus && cardEloStatus) {
|
|
130
|
+
const user = (results[0] as PromiseFulfilledResult<any>).value;
|
|
131
|
+
const card = (results[1] as PromiseFulfilledResult<any>).value;
|
|
132
|
+
|
|
133
|
+
if (user.ok && card && card.ok) {
|
|
134
|
+
const tagCount = Object.keys(taggedPerformance).length - 1; // exclude _global
|
|
135
|
+
logger.info(
|
|
136
|
+
`[EloService] Updated ELOS (per-tag, ${tagCount} tags):
|
|
137
|
+
\tUser: ${JSON.stringify(eloUpdate.userElo)})
|
|
138
|
+
\tCard: ${JSON.stringify(eloUpdate.cardElo)})
|
|
139
|
+
`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
// Log which operations succeeded and which failed
|
|
144
|
+
logger.warn(
|
|
145
|
+
`[EloService] Partial ELO update (per-tag):
|
|
146
|
+
\tUser ELO update: ${userEloStatus ? 'SUCCESS' : 'FAILED'}
|
|
147
|
+
\tCard ELO update: ${cardEloStatus ? 'SUCCESS' : 'FAILED'}`
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (!userEloStatus && results[0].status === 'rejected') {
|
|
151
|
+
logger.error('[EloService] User ELO update error:', results[0].reason);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!cardEloStatus && results[1].status === 'rejected') {
|
|
155
|
+
logger.error('[EloService] Card ELO update error:', results[1].reason);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -10,6 +10,17 @@ import { logger } from '@db/util/logger';
|
|
|
10
10
|
import { ResponseResult, StudySessionRecord } from '../SessionController';
|
|
11
11
|
import { EloService } from './EloService';
|
|
12
12
|
import { SrsService } from './SrsService';
|
|
13
|
+
import { Performance, isTaggedPerformance, TaggedPerformance } from '@vue-skuilder/common';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parsed performance data for ELO updates.
|
|
17
|
+
*/
|
|
18
|
+
interface ParsedPerformance {
|
|
19
|
+
/** Global score for SRS and global ELO [0, 1] */
|
|
20
|
+
globalScore: number;
|
|
21
|
+
/** Per-tag scores, or null if using simple numeric performance */
|
|
22
|
+
taggedPerformance: TaggedPerformance | null;
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
/**
|
|
15
26
|
* Service responsible for orchestrating the complete response processing workflow.
|
|
@@ -24,6 +35,39 @@ export class ResponseProcessor {
|
|
|
24
35
|
this.eloService = eloService;
|
|
25
36
|
}
|
|
26
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Parses performance data into global score and optional per-tag scores.
|
|
40
|
+
*
|
|
41
|
+
* @param performance - Numeric or structured performance from QuestionRecord
|
|
42
|
+
* @returns Parsed performance with global score and optional tag scores
|
|
43
|
+
*/
|
|
44
|
+
private parsePerformance(performance: Performance): ParsedPerformance {
|
|
45
|
+
if (typeof performance === 'number') {
|
|
46
|
+
// Simple numeric performance - backward compatible
|
|
47
|
+
return {
|
|
48
|
+
globalScore: performance,
|
|
49
|
+
taggedPerformance: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Structured TaggedPerformance with _global and per-tag scores
|
|
54
|
+
if (isTaggedPerformance(performance)) {
|
|
55
|
+
return {
|
|
56
|
+
globalScore: performance._global,
|
|
57
|
+
taggedPerformance: performance,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fallback for unexpected structure - treat as neutral
|
|
62
|
+
logger.warn('[ResponseProcessor] Unexpected performance structure, using neutral score', {
|
|
63
|
+
performance,
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
globalScore: 0.5,
|
|
67
|
+
taggedPerformance: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
/**
|
|
28
72
|
* Processes a user's response to a card, handling SRS scheduling and ELO updates.
|
|
29
73
|
* @param cardRecord User's response record
|
|
@@ -60,43 +104,9 @@ export class ResponseProcessor {
|
|
|
60
104
|
};
|
|
61
105
|
}
|
|
62
106
|
|
|
63
|
-
// Debug logging for response processing
|
|
64
|
-
// logger.debug('[ResponseProcessor] Processing response', {
|
|
65
|
-
// cardId,
|
|
66
|
-
// courseId,
|
|
67
|
-
// isCorrect: cardRecord.isCorrect,
|
|
68
|
-
// performance: cardRecord.performance,
|
|
69
|
-
// priorAttempts: cardRecord.priorAttemps,
|
|
70
|
-
// currentSessionViews: sessionViews,
|
|
71
|
-
// maxSessionViews,
|
|
72
|
-
// maxAttemptsPerView,
|
|
73
|
-
// currentCardRecordsLength: currentCard.records.length,
|
|
74
|
-
// studySessionSourceType: studySessionItem.contentSourceType,
|
|
75
|
-
// studySessionSourceID: studySessionItem.contentSourceID,
|
|
76
|
-
// studySessionItemId: studySessionItem.cardID,
|
|
77
|
-
// studySessionItemType: studySessionItem.contentSourceType,
|
|
78
|
-
|
|
79
|
-
// cardRecordTimestamp: cardRecord.timeStamp,
|
|
80
|
-
// cardRecordResponseTime: cardRecord.timeSpent,
|
|
81
|
-
// });
|
|
82
|
-
|
|
83
107
|
try {
|
|
84
108
|
const history = await cardHistory;
|
|
85
109
|
|
|
86
|
-
// Debug logging for card history
|
|
87
|
-
// logger.debug('[ResponseProcessor] History loaded:', {
|
|
88
|
-
// cardId,
|
|
89
|
-
// historyRecordsCount: history.records.length,
|
|
90
|
-
// historyRecords: history.records.map((record) => ({
|
|
91
|
-
// timeStamp: record.timeStamp,
|
|
92
|
-
// isCorrect: 'isCorrect' in record ? record.isCorrect : 'N/A',
|
|
93
|
-
// performance: 'performance' in record ? record.performance : 'N/A',
|
|
94
|
-
// priorAttempts: 'priorAttemps' in record ? record.priorAttemps : 'N/A',
|
|
95
|
-
// })),
|
|
96
|
-
// firstInteraction: history.records.length === 1,
|
|
97
|
-
// lastRecord: history.records[history.records.length - 1],
|
|
98
|
-
// });
|
|
99
|
-
|
|
100
110
|
// Handle correct responses
|
|
101
111
|
if (cardRecord.isCorrect) {
|
|
102
112
|
return this.processCorrectResponse(
|
|
@@ -145,40 +155,57 @@ export class ResponseProcessor {
|
|
|
145
155
|
// Schedule the card for future review based on performance (async, non-blocking)
|
|
146
156
|
void this.srsService.scheduleReview(history, studySessionItem);
|
|
147
157
|
|
|
158
|
+
// Parse performance (may be numeric or structured)
|
|
159
|
+
const { globalScore, taggedPerformance } = this.parsePerformance(cardRecord.performance);
|
|
160
|
+
|
|
148
161
|
// Update ELO ratings
|
|
149
|
-
if (
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
userScore,
|
|
162
|
+
if (taggedPerformance) {
|
|
163
|
+
// Per-tag ELO update
|
|
164
|
+
void this.eloService.updateUserAndCardEloPerTag(
|
|
165
|
+
taggedPerformance,
|
|
154
166
|
courseId,
|
|
155
167
|
cardId,
|
|
156
168
|
courseRegistrationDoc,
|
|
157
169
|
currentCard
|
|
158
170
|
);
|
|
171
|
+
logger.info(
|
|
172
|
+
`[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
|
|
173
|
+
);
|
|
159
174
|
} else {
|
|
160
|
-
//
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
175
|
+
// Standard single-score ELO update (backward compatible)
|
|
176
|
+
const userScore = 0.5 + globalScore / 2;
|
|
177
|
+
|
|
178
|
+
if (history.records.length === 1) {
|
|
179
|
+
// First interaction with this card - standard ELO update
|
|
180
|
+
void this.eloService.updateUserAndCardElo(
|
|
181
|
+
userScore,
|
|
182
|
+
courseId,
|
|
183
|
+
cardId,
|
|
184
|
+
courseRegistrationDoc,
|
|
185
|
+
currentCard
|
|
186
|
+
);
|
|
187
|
+
} else {
|
|
188
|
+
// Multiple interactions - reduce K-factor to limit ELO volatility
|
|
189
|
+
const k = Math.ceil(32 / history.records.length);
|
|
190
|
+
void this.eloService.updateUserAndCardElo(
|
|
191
|
+
userScore,
|
|
192
|
+
courseId,
|
|
193
|
+
cardId,
|
|
194
|
+
courseRegistrationDoc,
|
|
195
|
+
currentCard,
|
|
196
|
+
k
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
logger.info(
|
|
200
|
+
'[ResponseProcessor] Processed correct response with SRS scheduling and ELO update'
|
|
170
201
|
);
|
|
171
202
|
}
|
|
172
203
|
|
|
173
|
-
logger.info(
|
|
174
|
-
'[ResponseProcessor] Processed correct response with SRS scheduling and ELO update'
|
|
175
|
-
);
|
|
176
|
-
|
|
177
204
|
return {
|
|
178
205
|
nextCardAction: 'dismiss-success',
|
|
179
206
|
shouldLoadNextCard: true,
|
|
180
207
|
isCorrect: true,
|
|
181
|
-
performanceScore:
|
|
208
|
+
performanceScore: globalScore,
|
|
182
209
|
shouldClearFeedbackShadow: true,
|
|
183
210
|
};
|
|
184
211
|
} else {
|
|
@@ -186,11 +213,13 @@ export class ResponseProcessor {
|
|
|
186
213
|
'[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)'
|
|
187
214
|
);
|
|
188
215
|
|
|
216
|
+
const { globalScore } = this.parsePerformance(cardRecord.performance);
|
|
217
|
+
|
|
189
218
|
return {
|
|
190
219
|
nextCardAction: 'marked-failed',
|
|
191
220
|
shouldLoadNextCard: true,
|
|
192
221
|
isCorrect: true,
|
|
193
|
-
performanceScore:
|
|
222
|
+
performanceScore: globalScore,
|
|
194
223
|
shouldClearFeedbackShadow: true,
|
|
195
224
|
};
|
|
196
225
|
}
|
|
@@ -210,16 +239,34 @@ export class ResponseProcessor {
|
|
|
210
239
|
maxSessionViews: number,
|
|
211
240
|
sessionViews: number
|
|
212
241
|
): ResponseResult {
|
|
213
|
-
//
|
|
242
|
+
// Parse performance (may be numeric or structured)
|
|
243
|
+
const { taggedPerformance } = this.parsePerformance(cardRecord.performance);
|
|
244
|
+
|
|
245
|
+
// Update ELO for first-time failures (not subsequent attempts on same card)
|
|
214
246
|
if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
247
|
+
if (taggedPerformance) {
|
|
248
|
+
// Per-tag ELO update for incorrect response
|
|
249
|
+
void this.eloService.updateUserAndCardEloPerTag(
|
|
250
|
+
taggedPerformance,
|
|
251
|
+
courseId,
|
|
252
|
+
cardId,
|
|
253
|
+
courseRegistrationDoc,
|
|
254
|
+
currentCard
|
|
255
|
+
);
|
|
256
|
+
logger.info(
|
|
257
|
+
`[ResponseProcessor] Processed incorrect response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
|
|
258
|
+
);
|
|
259
|
+
} else {
|
|
260
|
+
// Standard single-score ELO update
|
|
261
|
+
void this.eloService.updateUserAndCardElo(
|
|
262
|
+
0, // Failed response = 0 score
|
|
263
|
+
courseId,
|
|
264
|
+
cardId,
|
|
265
|
+
courseRegistrationDoc,
|
|
266
|
+
currentCard
|
|
267
|
+
);
|
|
268
|
+
logger.info('[ResponseProcessor] Processed incorrect response with ELO update');
|
|
269
|
+
}
|
|
223
270
|
} else {
|
|
224
271
|
logger.info('[ResponseProcessor] Processed incorrect response (no ELO update needed)');
|
|
225
272
|
}
|
|
@@ -227,14 +274,25 @@ export class ResponseProcessor {
|
|
|
227
274
|
// Determine navigation based on attempt limits
|
|
228
275
|
if (currentCard.records.length >= maxAttemptsPerView) {
|
|
229
276
|
if (sessionViews >= maxSessionViews) {
|
|
230
|
-
// Too many session views - dismiss completely with ELO penalty
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
277
|
+
// Too many session views - dismiss completely with ELO penalty
|
|
278
|
+
if (taggedPerformance) {
|
|
279
|
+
// Use tagged performance for final failure
|
|
280
|
+
void this.eloService.updateUserAndCardEloPerTag(
|
|
281
|
+
taggedPerformance,
|
|
282
|
+
courseId,
|
|
283
|
+
cardId,
|
|
284
|
+
courseRegistrationDoc,
|
|
285
|
+
currentCard
|
|
286
|
+
);
|
|
287
|
+
} else {
|
|
288
|
+
void this.eloService.updateUserAndCardElo(
|
|
289
|
+
0,
|
|
290
|
+
courseId,
|
|
291
|
+
cardId,
|
|
292
|
+
courseRegistrationDoc,
|
|
293
|
+
currentCard
|
|
294
|
+
);
|
|
295
|
+
}
|
|
238
296
|
return {
|
|
239
297
|
nextCardAction: 'dismiss-failed',
|
|
240
298
|
shouldLoadNextCard: true,
|
|
@@ -14,6 +14,15 @@ export class SrsService {
|
|
|
14
14
|
this.user = user;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Remove a scheduled review from the user's database.
|
|
19
|
+
* Used to clean up orphaned reviews (e.g., card deleted from course DB).
|
|
20
|
+
*/
|
|
21
|
+
removeReview(reviewID: string): void {
|
|
22
|
+
logger.info(`[SrsService] Removing orphaned scheduled review: ${reviewID}`);
|
|
23
|
+
void this.user.removeScheduledCardReview(reviewID);
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
/**
|
|
18
27
|
* Calculates the next review time for a card based on its history and
|
|
19
28
|
* schedules it in the user's database.
|
|
@@ -90,6 +90,7 @@ function createMockContext(): { user: UserDBInterface; course: CourseDBInterface
|
|
|
90
90
|
getCourseRegDoc: vi.fn().mockResolvedValue({
|
|
91
91
|
elo: { global: { score: 1000, count: 10 }, tags: {} },
|
|
92
92
|
}),
|
|
93
|
+
getUsername: vi.fn().mockReturnValue('test-user'),
|
|
93
94
|
} as unknown as UserDBInterface;
|
|
94
95
|
|
|
95
96
|
const mockCourse = {
|
|
@@ -294,6 +295,7 @@ describe('Pipeline', () => {
|
|
|
294
295
|
|
|
295
296
|
const failingUser = {
|
|
296
297
|
getCourseRegDoc: vi.fn().mockRejectedValue(new Error('Not registered')),
|
|
298
|
+
getUsername: vi.fn().mockReturnValue('failing-user'),
|
|
297
299
|
} as unknown as UserDBInterface;
|
|
298
300
|
|
|
299
301
|
let capturedElo = 0;
|
|
@@ -175,15 +175,15 @@ describe('PipelineAssembler', () => {
|
|
|
175
175
|
expect(result.warnings).toEqual([]);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it('uses default ELO when filters exist but no generator', async () => {
|
|
178
|
+
it('uses default ELO and SRS when filters exist but no generator', async () => {
|
|
179
179
|
const hierarchy = createStrategy('hierarchy', 'hierarchyDefinition');
|
|
180
180
|
const input = createInput([hierarchy]);
|
|
181
181
|
const result = await assembler.assemble(input);
|
|
182
182
|
|
|
183
183
|
expect(result.pipeline).toBeInstanceOf(Pipeline);
|
|
184
|
-
expect(result.generatorStrategies).toHaveLength(
|
|
185
|
-
|
|
186
|
-
expect(
|
|
184
|
+
expect(result.generatorStrategies).toHaveLength(2);
|
|
185
|
+
const strategyNames = result.generatorStrategies.map((s) => s.name).sort();
|
|
186
|
+
expect(strategyNames).toEqual(['ELO (default)', 'SRS (default)']);
|
|
187
187
|
expect(result.filterStrategies).toEqual([hierarchy]);
|
|
188
188
|
expect(result.warnings).toEqual([]);
|
|
189
189
|
});
|