@vue-skuilder/db 0.1.18 → 0.1.20
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/{classroomDB-BgfrVb8d.d.ts → classroomDB-CZdMBiTU.d.ts} +71 -2
- package/dist/{classroomDB-CTOenngH.d.cts → classroomDB-PxDZTky3.d.cts} +71 -2
- package/dist/core/index.d.cts +80 -6
- package/dist/core/index.d.ts +80 -6
- package/dist/core/index.js +370 -52
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +369 -52
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
- package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +4 -3
- package/dist/impl/couch/index.d.ts +4 -3
- package/dist/impl/couch/index.js +371 -55
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +371 -55
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +5 -4
- package/dist/impl/static/index.d.ts +5 -4
- package/dist/impl/static/index.js +356 -44
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +356 -44
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
- package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
- package/dist/index.d.cts +10 -10
- package/dist/index.d.ts +10 -10
- package/dist/index.js +382 -55
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +381 -55
- package/dist/index.mjs.map +1 -1
- package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
- package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
- package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
- package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/navigators-architecture.md +115 -10
- package/package.json +4 -4
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/courseDB.ts +13 -0
- package/src/core/interfaces/userDB.ts +32 -0
- package/src/core/navigators/Pipeline.ts +127 -14
- package/src/core/navigators/filters/index.ts +3 -0
- package/src/core/navigators/filters/userTagPreference.ts +232 -0
- package/src/core/navigators/hierarchyDefinition.ts +4 -4
- package/src/core/navigators/index.ts +59 -0
- package/src/core/navigators/inferredPreference.ts +107 -0
- package/src/core/navigators/interferenceMitigator.ts +1 -13
- package/src/core/navigators/relativePriority.ts +2 -14
- package/src/core/navigators/userGoal.ts +136 -0
- package/src/core/types/strategyState.ts +84 -0
- package/src/core/types/types-legacy.ts +2 -0
- package/src/impl/common/BaseUserDB.ts +74 -7
- package/src/impl/couch/adminDB.ts +1 -2
- package/src/impl/couch/courseDB.ts +30 -10
- package/src/impl/static/courseDB.ts +11 -0
- package/tests/core/navigators/Pipeline.test.ts +1 -0
- package/docs/todo-pipeline-optimization.md +0 -117
- package/docs/todo-strategy-state-storage.md +0 -278
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { DocType, DocTypePrefixes } from './types-legacy';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Template literal type for strategy state document IDs.
|
|
5
|
+
*
|
|
6
|
+
* Format: `STRATEGY_STATE-{courseId}-{strategyKey}`
|
|
7
|
+
*/
|
|
8
|
+
export type StrategyStateId =
|
|
9
|
+
`${(typeof DocTypePrefixes)[DocType.STRATEGY_STATE]}::${string}::${string}`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Document storing strategy-specific state in the user database.
|
|
13
|
+
*
|
|
14
|
+
* Each strategy can persist its own state (user preferences, learned patterns,
|
|
15
|
+
* temporal tracking, etc.) using this document type. The state is scoped to
|
|
16
|
+
* a (user, course, strategy) tuple.
|
|
17
|
+
*
|
|
18
|
+
* ## Use Cases
|
|
19
|
+
*
|
|
20
|
+
* 1. **Explicit user preferences**: User configures tag filters, difficulty
|
|
21
|
+
* preferences, or learning goals. UI writes to strategy state.
|
|
22
|
+
*
|
|
23
|
+
* 2. **Learned/temporal state**: Strategy tracks patterns over time, e.g.,
|
|
24
|
+
* "when did I last introduce confusable concepts together?"
|
|
25
|
+
*
|
|
26
|
+
* 3. **Adaptive personalization**: Strategy infers user preferences from
|
|
27
|
+
* behavior and stores them for future sessions.
|
|
28
|
+
*
|
|
29
|
+
* ## Storage Location
|
|
30
|
+
*
|
|
31
|
+
* These documents live in the **user database**, not the course database.
|
|
32
|
+
* They sync with the user's data across devices.
|
|
33
|
+
*
|
|
34
|
+
* ## Document ID Format
|
|
35
|
+
*
|
|
36
|
+
* `STRATEGY_STATE::{courseId}::{strategyKey}`
|
|
37
|
+
*
|
|
38
|
+
* Example: `STRATEGY_STATE::piano-basics::UserTagPreferenceFilter`
|
|
39
|
+
*
|
|
40
|
+
* @template T - The shape of the strategy-specific data payload
|
|
41
|
+
*/
|
|
42
|
+
export interface StrategyStateDoc<T = unknown> {
|
|
43
|
+
_id: StrategyStateId;
|
|
44
|
+
_rev?: string;
|
|
45
|
+
docType: DocType.STRATEGY_STATE;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The course this state applies to.
|
|
49
|
+
*/
|
|
50
|
+
courseId: string;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Unique key identifying the strategy instance.
|
|
54
|
+
* Typically the strategy class name (e.g., "UserTagPreferenceFilter",
|
|
55
|
+
* "InterferenceMitigatorNavigator").
|
|
56
|
+
*
|
|
57
|
+
* If a course has multiple instances of the same strategy type with
|
|
58
|
+
* different configurations, use a more specific key.
|
|
59
|
+
*/
|
|
60
|
+
strategyKey: string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Strategy-specific data payload.
|
|
64
|
+
* Each strategy defines its own schema for this field.
|
|
65
|
+
*/
|
|
66
|
+
data: T;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* ISO timestamp of last update.
|
|
70
|
+
* Use `moment.utc(updatedAt)` to parse into a Moment object.
|
|
71
|
+
*/
|
|
72
|
+
updatedAt: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the document ID for a strategy state document.
|
|
77
|
+
*
|
|
78
|
+
* @param courseId - The course ID
|
|
79
|
+
* @param strategyKey - The strategy key (typically class name)
|
|
80
|
+
* @returns The document ID in format `STRATEGY_STATE::{courseId}::{strategyKey}`
|
|
81
|
+
*/
|
|
82
|
+
export function buildStrategyStateId(courseId: string, strategyKey: string): StrategyStateId {
|
|
83
|
+
return `STRATEGY_STATE::${courseId}::${strategyKey}`;
|
|
84
|
+
}
|
|
@@ -19,6 +19,7 @@ export enum DocType {
|
|
|
19
19
|
SCHEDULED_CARD = 'SCHEDULED_CARD',
|
|
20
20
|
TAG = 'TAG',
|
|
21
21
|
NAVIGATION_STRATEGY = 'NAVIGATION_STRATEGY',
|
|
22
|
+
STRATEGY_STATE = 'STRATEGY_STATE',
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface QualifiedCardID {
|
|
@@ -103,6 +104,7 @@ export const DocTypePrefixes = {
|
|
|
103
104
|
[DocType.VIEW]: 'VIEW',
|
|
104
105
|
[DocType.PEDAGOGY]: 'PEDAGOGY',
|
|
105
106
|
[DocType.NAVIGATION_STRATEGY]: 'NAVIGATION_STRATEGY',
|
|
107
|
+
[DocType.STRATEGY_STATE]: 'STRATEGY_STATE',
|
|
106
108
|
} as const;
|
|
107
109
|
|
|
108
110
|
export interface CardHistory<T extends CardRecord> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DocType, DocTypePrefixes } from '@db/core';
|
|
1
|
+
import { DocType, DocTypePrefixes, StrategyStateDoc, buildStrategyStateId } from '@db/core';
|
|
2
2
|
import { getCardHistoryID } from '@db/core/util';
|
|
3
3
|
import { CourseElo, Status } from '@vue-skuilder/common';
|
|
4
4
|
import moment, { Moment } from 'moment';
|
|
@@ -1046,6 +1046,61 @@ Currently logged-in as ${this._username}.`
|
|
|
1046
1046
|
public async updateUserElo(courseId: string, elo: CourseElo): Promise<PouchDB.Core.Response> {
|
|
1047
1047
|
return updateUserElo(this._username, courseId, elo);
|
|
1048
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
public async getStrategyState<T>(courseId: string, strategyKey: string): Promise<T | null> {
|
|
1051
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
1052
|
+
try {
|
|
1053
|
+
const doc = await this.localDB.get<StrategyStateDoc<T>>(docId);
|
|
1054
|
+
return doc.data;
|
|
1055
|
+
} catch (e) {
|
|
1056
|
+
const err = e as PouchError;
|
|
1057
|
+
if (err.status === 404) {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
throw e;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
public async putStrategyState<T>(courseId: string, strategyKey: string, data: T): Promise<void> {
|
|
1065
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
1066
|
+
let existingRev: string | undefined;
|
|
1067
|
+
|
|
1068
|
+
try {
|
|
1069
|
+
const existing = await this.localDB.get<StrategyStateDoc<T>>(docId);
|
|
1070
|
+
existingRev = existing._rev;
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
const err = e as PouchError;
|
|
1073
|
+
if (err.status !== 404) {
|
|
1074
|
+
throw e;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const doc: StrategyStateDoc<T> = {
|
|
1079
|
+
_id: docId,
|
|
1080
|
+
_rev: existingRev,
|
|
1081
|
+
docType: DocType.STRATEGY_STATE,
|
|
1082
|
+
courseId,
|
|
1083
|
+
strategyKey,
|
|
1084
|
+
data,
|
|
1085
|
+
updatedAt: new Date().toISOString(),
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
await this.localDB.put(doc);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
public async deleteStrategyState(courseId: string, strategyKey: string): Promise<void> {
|
|
1092
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
1093
|
+
try {
|
|
1094
|
+
const doc = await this.localDB.get(docId);
|
|
1095
|
+
await this.localDB.remove(doc);
|
|
1096
|
+
} catch (e) {
|
|
1097
|
+
const err = e as PouchError;
|
|
1098
|
+
if (err.status === 404) {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
throw e;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1049
1104
|
}
|
|
1050
1105
|
|
|
1051
1106
|
export function accomodateGuest(): {
|
|
@@ -1056,7 +1111,9 @@ export function accomodateGuest(): {
|
|
|
1056
1111
|
|
|
1057
1112
|
// Check if localStorage is available (browser environment)
|
|
1058
1113
|
if (typeof localStorage === 'undefined') {
|
|
1059
|
-
logger.log(
|
|
1114
|
+
logger.log(
|
|
1115
|
+
'[funnel] localStorage not available (Node.js environment), returning default guest'
|
|
1116
|
+
);
|
|
1060
1117
|
return {
|
|
1061
1118
|
username: GuestUsername + 'nodejs-test',
|
|
1062
1119
|
firstVisit: true,
|
|
@@ -1125,11 +1182,21 @@ export function accomodateGuest(): {
|
|
|
1125
1182
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
|
|
1126
1183
|
|
|
1127
1184
|
const uuid = [
|
|
1128
|
-
Array.from(bytes.slice(0, 4))
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
Array.from(bytes.slice(
|
|
1132
|
-
|
|
1185
|
+
Array.from(bytes.slice(0, 4))
|
|
1186
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1187
|
+
.join(''),
|
|
1188
|
+
Array.from(bytes.slice(4, 6))
|
|
1189
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1190
|
+
.join(''),
|
|
1191
|
+
Array.from(bytes.slice(6, 8))
|
|
1192
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1193
|
+
.join(''),
|
|
1194
|
+
Array.from(bytes.slice(8, 10))
|
|
1195
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1196
|
+
.join(''),
|
|
1197
|
+
Array.from(bytes.slice(10, 16))
|
|
1198
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1199
|
+
.join(''),
|
|
1133
1200
|
].join('-');
|
|
1134
1201
|
|
|
1135
1202
|
logger.log('[funnel] Generated UUID using crypto.getRandomValues():', uuid);
|
|
@@ -270,16 +270,6 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
|
|
|
270
270
|
}
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
-
await Promise.all(
|
|
274
|
-
cards.rows.map((r) => {
|
|
275
|
-
return async () => {
|
|
276
|
-
if (isSuccessRow(r)) {
|
|
277
|
-
ret[r.id] = r.doc!.id_displayable_data;
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
})
|
|
281
|
-
);
|
|
282
|
-
|
|
283
273
|
return ret;
|
|
284
274
|
}
|
|
285
275
|
|
|
@@ -379,6 +369,36 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
379
369
|
}
|
|
380
370
|
}
|
|
381
371
|
|
|
372
|
+
async getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>> {
|
|
373
|
+
if (cardIds.length === 0) {
|
|
374
|
+
return new Map();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const db = getCourseDB(this.id);
|
|
378
|
+
const result = await db.query<TagStub>('getTags', {
|
|
379
|
+
keys: cardIds,
|
|
380
|
+
include_docs: false,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const tagsByCard = new Map<string, string[]>();
|
|
384
|
+
|
|
385
|
+
// Initialize all requested cards with empty arrays
|
|
386
|
+
for (const cardId of cardIds) {
|
|
387
|
+
tagsByCard.set(cardId, []);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Populate from query results
|
|
391
|
+
for (const row of result.rows) {
|
|
392
|
+
const cardId = row.key as string;
|
|
393
|
+
const tagName = row.value?.name;
|
|
394
|
+
if (tagName && tagsByCard.has(cardId)) {
|
|
395
|
+
tagsByCard.get(cardId)!.push(tagName);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return tagsByCard;
|
|
400
|
+
}
|
|
401
|
+
|
|
382
402
|
async addTagToCard(
|
|
383
403
|
cardId: string,
|
|
384
404
|
tagId: string,
|
|
@@ -249,6 +249,17 @@ export class StaticCourseDB implements CourseDBInterface {
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
async getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>> {
|
|
253
|
+
const tagsIndex = await this.unpacker.getTagsIndex();
|
|
254
|
+
const tagsByCard = new Map<string, string[]>();
|
|
255
|
+
|
|
256
|
+
for (const cardId of cardIds) {
|
|
257
|
+
tagsByCard.set(cardId, tagsIndex.byCard[cardId] || []);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return tagsByCard;
|
|
261
|
+
}
|
|
262
|
+
|
|
252
263
|
async addTagToCard(_cardId: string, _tagId: string): Promise<PouchDB.Core.Response> {
|
|
253
264
|
throw new Error('Cannot modify tags in static mode');
|
|
254
265
|
}
|
|
@@ -102,6 +102,7 @@ function createMockContext(): { user: UserDBInterface; course: CourseDBInterface
|
|
|
102
102
|
|
|
103
103
|
const mockCourse = {
|
|
104
104
|
getCourseID: vi.fn().mockReturnValue('test-course'),
|
|
105
|
+
getAppliedTagsBatch: vi.fn().mockResolvedValue(new Map()),
|
|
105
106
|
} as unknown as CourseDBInterface;
|
|
106
107
|
|
|
107
108
|
return { user: mockUser, course: mockCourse };
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
# TODO: Pipeline Optimization - Batch Tag Hydration
|
|
2
|
-
|
|
3
|
-
## Status: NOT STARTED
|
|
4
|
-
|
|
5
|
-
## Problem
|
|
6
|
-
|
|
7
|
-
Each filter strategy independently queries for card tags, resulting in redundant database operations.
|
|
8
|
-
|
|
9
|
-
For N cards through 3 filters = 3N tag lookups, when N would suffice.
|
|
10
|
-
|
|
11
|
-
```typescript
|
|
12
|
-
// In HierarchyDefinitionNavigator
|
|
13
|
-
const tagResponse = await context.course.getAppliedTags(card.cardId);
|
|
14
|
-
|
|
15
|
-
// In InterferenceMitigatorNavigator
|
|
16
|
-
const tagResponse = await context.course.getAppliedTags(card.cardId);
|
|
17
|
-
|
|
18
|
-
// In RelativePriorityNavigator
|
|
19
|
-
const tagResponse = await context.course.getAppliedTags(card.cardId);
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Proposed Solution: Hydrate Tags in WeightedCard
|
|
23
|
-
|
|
24
|
-
Extend `WeightedCard` to optionally carry pre-fetched tag data:
|
|
25
|
-
|
|
26
|
-
```typescript
|
|
27
|
-
interface WeightedCard {
|
|
28
|
-
cardId: string;
|
|
29
|
-
courseId: string;
|
|
30
|
-
score: number;
|
|
31
|
-
provenance: StrategyContribution[];
|
|
32
|
-
|
|
33
|
-
/** Pre-fetched tags. If present, filters should use this instead of querying. */
|
|
34
|
-
tags?: string[];
|
|
35
|
-
}
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
### Implementation Steps
|
|
39
|
-
|
|
40
|
-
#### Step 1: Add batch tag lookup method
|
|
41
|
-
|
|
42
|
-
```typescript
|
|
43
|
-
// In CourseDBInterface
|
|
44
|
-
getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>>;
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
#### Step 2: Update WeightedCard type
|
|
48
|
-
|
|
49
|
-
Add optional `tags?: string[]` field to `WeightedCard` in `core/navigators/index.ts`.
|
|
50
|
-
|
|
51
|
-
#### Step 3: Hydrate in Pipeline
|
|
52
|
-
|
|
53
|
-
The `Pipeline` class should batch-fetch tags after getting candidates from the generator:
|
|
54
|
-
|
|
55
|
-
```typescript
|
|
56
|
-
async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
57
|
-
const context = await this.buildContext();
|
|
58
|
-
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
59
|
-
|
|
60
|
-
// Batch hydrate tags
|
|
61
|
-
cards = await this.hydrateTags(cards);
|
|
62
|
-
|
|
63
|
-
for (const filter of this.filters) {
|
|
64
|
-
cards = await filter.transform(cards, context);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return cards.filter(c => c.score > 0)
|
|
68
|
-
.sort((a, b) => b.score - a.score)
|
|
69
|
-
.slice(0, limit);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
private async hydrateTags(cards: WeightedCard[]): Promise<WeightedCard[]> {
|
|
73
|
-
const cardIds = cards.map(c => c.cardId);
|
|
74
|
-
const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
|
|
75
|
-
|
|
76
|
-
return cards.map(c => ({
|
|
77
|
-
...c,
|
|
78
|
-
tags: tagsByCard.get(c.cardId) ?? []
|
|
79
|
-
}));
|
|
80
|
-
}
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
#### Step 4: Update filter strategies
|
|
84
|
-
|
|
85
|
-
Each filter checks for pre-hydrated tags before querying:
|
|
86
|
-
|
|
87
|
-
```typescript
|
|
88
|
-
const cardTags = card.tags ?? await this.getCardTags(card.cardId, context.course);
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
#### Step 5: Add tests
|
|
92
|
-
|
|
93
|
-
- Verify tags are populated by Pipeline
|
|
94
|
-
- Verify filters use pre-fetched tags when available
|
|
95
|
-
- Verify fallback works if tags missing
|
|
96
|
-
|
|
97
|
-
## Files to Modify
|
|
98
|
-
|
|
99
|
-
| File | Change |
|
|
100
|
-
|------|--------|
|
|
101
|
-
| `core/navigators/index.ts` | Add `tags?` to `WeightedCard` |
|
|
102
|
-
| `core/interfaces/courseDB.ts` | Add `getAppliedTagsBatch()` |
|
|
103
|
-
| `impl/couch/courseDB.ts` | Implement `getAppliedTagsBatch()` |
|
|
104
|
-
| `impl/static/courseDB.ts` | Implement `getAppliedTagsBatch()` |
|
|
105
|
-
| `core/navigators/Pipeline.ts` | Add `hydrateTags()` step |
|
|
106
|
-
| `core/navigators/hierarchyDefinition.ts` | Use `card.tags` if available |
|
|
107
|
-
| `core/navigators/interferenceMitigator.ts` | Use `card.tags` if available |
|
|
108
|
-
| `core/navigators/relativePriority.ts` | Use `card.tags` if available |
|
|
109
|
-
|
|
110
|
-
## Performance Expectations
|
|
111
|
-
|
|
112
|
-
| Scenario | Before | After |
|
|
113
|
-
|----------|--------|-------|
|
|
114
|
-
| 20 cards, 3 filters | 60 tag queries | 1 batch query (20 cards) |
|
|
115
|
-
| 50 cards, 4 filters | 200 tag queries | 1 batch query (50 cards) |
|
|
116
|
-
|
|
117
|
-
Batch queries also reduce round-trip overhead compared to individual queries.
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
# TODO: Strategy-Specific State Storage in UserDB
|
|
2
|
-
|
|
3
|
-
## Status: NOT STARTED
|
|
4
|
-
|
|
5
|
-
## Goal
|
|
6
|
-
|
|
7
|
-
Enable NavigationStrategies (ContentNavigators) to persist their own state data in the
|
|
8
|
-
user's database, allowing strategies to maintain context across sessions.
|
|
9
|
-
|
|
10
|
-
## Current State
|
|
11
|
-
|
|
12
|
-
### What Strategies Can Read
|
|
13
|
-
|
|
14
|
-
| Data | Method | Notes |
|
|
15
|
-
|------|--------|-------|
|
|
16
|
-
| User's global ELO | `user.getCourseRegDoc(courseId).elo.global` | ✅ Available |
|
|
17
|
-
| User's per-tag ELO | `user.getCourseRegDoc(courseId).elo.tags` | ✅ Available |
|
|
18
|
-
| Seen cards | `user.getSeenCards(courseId)` | ✅ Card IDs only |
|
|
19
|
-
| Active cards | `user.getActiveCards()` | ✅ Available |
|
|
20
|
-
| Pending reviews | `user.getPendingReviews(courseId)` | ✅ ScheduledCard objects |
|
|
21
|
-
| Card history | `user.putCardRecord()` returns `CardHistory` | 🟡 Only after write |
|
|
22
|
-
|
|
23
|
-
### What Strategies Cannot Do
|
|
24
|
-
|
|
25
|
-
- **Store arbitrary state**: No namespaced storage for strategy-specific data
|
|
26
|
-
- **Track temporal patterns**: No easy way to record "when did I last introduce tag X?"
|
|
27
|
-
- **Persist learning context**: Strategy state is lost between sessions
|
|
28
|
-
|
|
29
|
-
## Use Cases
|
|
30
|
-
|
|
31
|
-
### 1. InterferenceMitigator
|
|
32
|
-
|
|
33
|
-
**Need**: Track when interfering concepts were last introduced together.
|
|
34
|
-
|
|
35
|
-
```typescript
|
|
36
|
-
// Desired: Store last introduction time per tag
|
|
37
|
-
{
|
|
38
|
-
"lastIntroduction": {
|
|
39
|
-
"letter-b": "2024-01-15T10:30:00Z",
|
|
40
|
-
"letter-d": "2024-01-16T14:20:00Z"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
### 2. Minimal Pairs Strategy (Future)
|
|
46
|
-
|
|
47
|
-
**Need**: Track discrimination training progress between confusable pairs.
|
|
48
|
-
|
|
49
|
-
```typescript
|
|
50
|
-
// Desired: Store discrimination scores per pair
|
|
51
|
-
{
|
|
52
|
-
"pairScores": {
|
|
53
|
-
"b-d": { "correct": 15, "total": 20, "lastPracticed": "..." },
|
|
54
|
-
"m-n": { "correct": 8, "total": 10, "lastPracticed": "..." }
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
### 3. Adaptive Pacing Strategy (Future)
|
|
60
|
-
|
|
61
|
-
**Need**: Track user's engagement patterns and optimal session timing.
|
|
62
|
-
|
|
63
|
-
```typescript
|
|
64
|
-
// Desired: Store engagement metrics
|
|
65
|
-
{
|
|
66
|
-
"sessionMetrics": {
|
|
67
|
-
"avgAccuracyByTimeOfDay": { "morning": 0.85, "afternoon": 0.78 },
|
|
68
|
-
"optimalSessionLength": 180, // seconds
|
|
69
|
-
"fatigueThreshold": 12 // cards before accuracy drops
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
## Proposed Solutions
|
|
75
|
-
|
|
76
|
-
### Option A: Extend CourseRegistration
|
|
77
|
-
|
|
78
|
-
Add a `strategyState` field to the existing `CourseRegistration` document.
|
|
79
|
-
|
|
80
|
-
**Schema:**
|
|
81
|
-
```typescript
|
|
82
|
-
interface CourseRegistration {
|
|
83
|
-
// ... existing fields ...
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Strategy-specific state, keyed by strategy ID or type.
|
|
87
|
-
* Each strategy owns its own namespace.
|
|
88
|
-
*/
|
|
89
|
-
strategyState?: {
|
|
90
|
-
[strategyKey: string]: unknown;
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
**Pros:**
|
|
96
|
-
- No new document types
|
|
97
|
-
- Lives alongside other course-specific user data
|
|
98
|
-
- Already synced via existing mechanisms
|
|
99
|
-
|
|
100
|
-
**Cons:**
|
|
101
|
-
- Potential for large documents if strategies store lots of data
|
|
102
|
-
- All strategies share one document (contention on updates)
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
### Option B: Separate Strategy State Documents
|
|
107
|
-
|
|
108
|
-
Create a new document type for strategy state.
|
|
109
|
-
|
|
110
|
-
**Schema:**
|
|
111
|
-
```typescript
|
|
112
|
-
interface StrategyStateDoc {
|
|
113
|
-
_id: `STRATEGY_STATE-${courseId}-${strategyKey}`;
|
|
114
|
-
docType: DocType.STRATEGY_STATE;
|
|
115
|
-
courseId: string;
|
|
116
|
-
strategyKey: string; // e.g., "interferenceMitigator" or strategy instance ID
|
|
117
|
-
data: unknown;
|
|
118
|
-
updatedAt: string;
|
|
119
|
-
}
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
**Pros:**
|
|
123
|
-
- Clean separation
|
|
124
|
-
- No document size concerns
|
|
125
|
-
- Independent updates (no contention)
|
|
126
|
-
|
|
127
|
-
**Cons:**
|
|
128
|
-
- New document type to manage
|
|
129
|
-
- More queries to fetch state
|
|
130
|
-
|
|
131
|
-
---
|
|
132
|
-
|
|
133
|
-
### Option C: Generic Key-Value Store in UserDB
|
|
134
|
-
|
|
135
|
-
Add generic methods for namespaced storage.
|
|
136
|
-
|
|
137
|
-
**Interface:**
|
|
138
|
-
```typescript
|
|
139
|
-
interface UserDBWriter {
|
|
140
|
-
// ... existing methods ...
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Store data in a namespaced location.
|
|
144
|
-
* @param namespace - Unique namespace (e.g., "strategy:interferenceMitigator:course123")
|
|
145
|
-
* @param data - Arbitrary JSON-serializable data
|
|
146
|
-
*/
|
|
147
|
-
putNamespacedData(namespace: string, data: unknown): Promise<void>;
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Retrieve namespaced data.
|
|
151
|
-
* @param namespace - The namespace to retrieve
|
|
152
|
-
* @returns The stored data, or null if not found
|
|
153
|
-
*/
|
|
154
|
-
getNamespacedData<T>(namespace: string): Promise<T | null>;
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Delete namespaced data.
|
|
158
|
-
* @param namespace - The namespace to delete
|
|
159
|
-
*/
|
|
160
|
-
deleteNamespacedData(namespace: string): Promise<void>;
|
|
161
|
-
}
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
**Pros:**
|
|
165
|
-
- Most flexible
|
|
166
|
-
- Reusable beyond strategies
|
|
167
|
-
- Clean API
|
|
168
|
-
|
|
169
|
-
**Cons:**
|
|
170
|
-
- Very generic (might be too permissive)
|
|
171
|
-
- Namespace collision risk
|
|
172
|
-
|
|
173
|
-
---
|
|
174
|
-
|
|
175
|
-
## Recommended Approach
|
|
176
|
-
|
|
177
|
-
**Option B (Separate Documents)** with a convenience wrapper:
|
|
178
|
-
|
|
179
|
-
```typescript
|
|
180
|
-
// In ContentNavigator base class or a mixin
|
|
181
|
-
abstract class ContentNavigator {
|
|
182
|
-
// ... existing methods ...
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Get this strategy's persisted state for the current course.
|
|
186
|
-
*/
|
|
187
|
-
protected async getStrategyState<T>(): Promise<T | null> {
|
|
188
|
-
const key = `STRATEGY_STATE-${this.course.getCourseID()}-${this.strategyKey}`;
|
|
189
|
-
try {
|
|
190
|
-
return await this.user.get<T>(key);
|
|
191
|
-
} catch (e) {
|
|
192
|
-
return null; // Not found
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Persist this strategy's state for the current course.
|
|
198
|
-
*/
|
|
199
|
-
protected async putStrategyState<T>(data: T): Promise<void> {
|
|
200
|
-
const key = `STRATEGY_STATE-${this.course.getCourseID()}-${this.strategyKey}`;
|
|
201
|
-
const existing = await this.getStrategyState<T>();
|
|
202
|
-
await this.user.put({
|
|
203
|
-
_id: key,
|
|
204
|
-
_rev: existing?._rev,
|
|
205
|
-
docType: DocType.STRATEGY_STATE,
|
|
206
|
-
courseId: this.course.getCourseID(),
|
|
207
|
-
strategyKey: this.strategyKey,
|
|
208
|
-
data,
|
|
209
|
-
updatedAt: new Date().toISOString(),
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Unique key for this strategy instance.
|
|
215
|
-
* Override in subclasses if multiple instances need separate state.
|
|
216
|
-
*/
|
|
217
|
-
protected get strategyKey(): string {
|
|
218
|
-
return this.constructor.name; // e.g., "InterferenceMitigatorNavigator"
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
## Implementation Plan
|
|
224
|
-
|
|
225
|
-
### Phase 1: Add DocType and Interface
|
|
226
|
-
|
|
227
|
-
1. Add `STRATEGY_STATE` to `DocType` enum in `packages/db/src/core/types/types-legacy.ts`
|
|
228
|
-
2. Define `StrategyStateDoc` interface
|
|
229
|
-
3. Add prefix to `DocTypePrefixes`
|
|
230
|
-
|
|
231
|
-
### Phase 2: Add UserDB Methods
|
|
232
|
-
|
|
233
|
-
1. Add `getStrategyState()` and `putStrategyState()` to `UserDBInterface`
|
|
234
|
-
2. Implement in `CouchUserDB`
|
|
235
|
-
|
|
236
|
-
### Phase 3: Add ContentNavigator Helpers
|
|
237
|
-
|
|
238
|
-
1. Add protected helper methods to `ContentNavigator` base class
|
|
239
|
-
2. Document usage pattern
|
|
240
|
-
|
|
241
|
-
### Phase 4: Update Strategies
|
|
242
|
-
|
|
243
|
-
1. Update `InterferenceMitigatorNavigator` to use state storage
|
|
244
|
-
2. Add tests for state persistence
|
|
245
|
-
|
|
246
|
-
## Files to Create/Modify
|
|
247
|
-
|
|
248
|
-
| File | Action | Description |
|
|
249
|
-
|------|--------|-------------|
|
|
250
|
-
| `packages/db/src/core/types/types-legacy.ts` | MODIFY | Add STRATEGY_STATE DocType |
|
|
251
|
-
| `packages/db/src/core/types/strategyState.ts` | CREATE | StrategyStateDoc interface |
|
|
252
|
-
| `packages/db/src/core/interfaces/userDB.ts` | MODIFY | Add state storage methods |
|
|
253
|
-
| `packages/db/src/impl/couch/userDB.ts` | MODIFY | Implement state storage |
|
|
254
|
-
| `packages/db/src/core/navigators/index.ts` | MODIFY | Add helper methods to base class |
|
|
255
|
-
| `packages/db/src/core/navigators/interferenceMitigator.ts` | MODIFY | Use state storage |
|
|
256
|
-
|
|
257
|
-
## Test Cases
|
|
258
|
-
|
|
259
|
-
1. **Store and retrieve**: Write state, read it back, verify equality
|
|
260
|
-
2. **Update existing**: Write state, update it, verify new value
|
|
261
|
-
3. **Separate namespaces**: Two strategies store data, each gets their own
|
|
262
|
-
4. **Cross-session persistence**: Store state, simulate new session, verify data persists
|
|
263
|
-
5. **Missing state**: Read state that doesn't exist, get null (not error)
|
|
264
|
-
|
|
265
|
-
## Open Questions
|
|
266
|
-
|
|
267
|
-
1. **State migration**: How to handle strategy state when strategy config changes?
|
|
268
|
-
2. **State cleanup**: When a strategy is deleted, should its state be cleaned up?
|
|
269
|
-
3. **State size limits**: Should we enforce maximum state size?
|
|
270
|
-
4. **Sync behavior**: How does state sync across devices for multi-device users?
|
|
271
|
-
|
|
272
|
-
## Related Files
|
|
273
|
-
|
|
274
|
-
- `packages/db/src/core/interfaces/userDB.ts` — UserDB interface
|
|
275
|
-
- `packages/db/src/impl/couch/userDB.ts` — UserDB implementation
|
|
276
|
-
- `packages/db/src/core/navigators/index.ts` — ContentNavigator base class
|
|
277
|
-
- `packages/db/src/core/types/types-legacy.ts` — DocType enum
|
|
278
|
-
- `packages/db/docs/navigators-architecture.md` — Architecture overview
|