@vue-skuilder/db 0.1.17 → 0.1.18
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/{userDB-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
- package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
- package/dist/core/index.d.cts +230 -0
- package/dist/core/index.d.ts +161 -23
- package/dist/core/index.js +1964 -154
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +1925 -121
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
- package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
- package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
- package/dist/impl/couch/index.d.ts +44 -3
- package/dist/impl/couch/index.js +1971 -171
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +1933 -134
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
- package/dist/impl/static/index.d.ts +2 -3
- package/dist/impl/static/index.js +1614 -119
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1585 -92
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
- package/dist/{index.d.mts → index.d.cts} +97 -13
- package/dist/index.d.ts +90 -6
- package/dist/index.js +2085 -153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2031 -106
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -3
- package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
- package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
- package/dist/util/packer/index.js.map +1 -1
- package/dist/util/packer/index.mjs.map +1 -1
- package/docs/brainstorm-navigation-paradigm.md +369 -0
- package/docs/navigators-architecture.md +265 -0
- package/docs/todo-evolutionary-orchestration.md +310 -0
- package/docs/todo-nominal-tag-types.md +121 -0
- package/docs/todo-pipeline-optimization.md +117 -0
- package/docs/todo-strategy-authoring.md +401 -0
- package/docs/todo-strategy-state-storage.md +278 -0
- package/eslint.config.mjs +1 -1
- package/package.json +9 -4
- package/src/core/interfaces/contentSource.ts +88 -4
- package/src/core/interfaces/navigationStrategyManager.ts +0 -5
- package/src/core/navigators/CompositeGenerator.ts +268 -0
- package/src/core/navigators/Pipeline.ts +205 -0
- package/src/core/navigators/PipelineAssembler.ts +194 -0
- package/src/core/navigators/elo.ts +104 -15
- package/src/core/navigators/filters/eloDistance.ts +132 -0
- package/src/core/navigators/filters/index.ts +6 -0
- package/src/core/navigators/filters/types.ts +115 -0
- package/src/core/navigators/generators/index.ts +2 -0
- package/src/core/navigators/generators/types.ts +107 -0
- package/src/core/navigators/hardcodedOrder.ts +111 -12
- package/src/core/navigators/hierarchyDefinition.ts +266 -0
- package/src/core/navigators/index.ts +345 -3
- package/src/core/navigators/interferenceMitigator.ts +367 -0
- package/src/core/navigators/relativePriority.ts +267 -0
- package/src/core/navigators/srs.ts +195 -0
- package/src/impl/couch/classroomDB.ts +51 -0
- package/src/impl/couch/courseDB.ts +117 -39
- package/src/impl/static/courseDB.ts +0 -4
- package/src/study/SessionController.ts +149 -1
- package/src/study/TagFilteredContentSource.ts +255 -0
- package/src/study/index.ts +1 -0
- package/src/util/dataDirectory.test.ts +51 -22
- package/src/util/logger.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
- package/tests/core/navigators/Pipeline.test.ts +405 -0
- package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
- package/tests/core/navigators/SRSNavigator.test.ts +344 -0
- package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
- package/tests/core/navigators/navigators.test.ts +710 -0
- package/tsconfig.json +1 -1
- package/vitest.config.ts +29 -0
- package/dist/core/index.d.mts +0 -92
- /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
- /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
- /package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-6ettoclI.d.cts} +0 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StudyContentSource,
|
|
3
|
+
StudySessionNewItem,
|
|
4
|
+
StudySessionReviewItem,
|
|
5
|
+
} from '@db/core/interfaces/contentSource';
|
|
6
|
+
import { WeightedCard } from '@db/core/navigators';
|
|
7
|
+
import { UserDBInterface } from '@db/core';
|
|
8
|
+
import { ScheduledCard } from '@db/core/types/user';
|
|
9
|
+
import { TagFilter, hasActiveFilter } from '@vue-skuilder/common';
|
|
10
|
+
import { getTag } from '../impl/couch/courseDB';
|
|
11
|
+
import { logger } from '@db/util/logger';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A StudyContentSource that filters cards based on tag inclusion/exclusion.
|
|
15
|
+
*
|
|
16
|
+
* This enables ephemeral, tag-scoped study sessions where users can focus
|
|
17
|
+
* on specific topics within a course without permanent configuration.
|
|
18
|
+
*
|
|
19
|
+
* Filter logic:
|
|
20
|
+
* - If `include` is non-empty: card must have at least one of the included tags
|
|
21
|
+
* - If `exclude` is non-empty: card must not have any of the excluded tags
|
|
22
|
+
* - Both filters are applied (include first, then exclude)
|
|
23
|
+
*/
|
|
24
|
+
export class TagFilteredContentSource implements StudyContentSource {
|
|
25
|
+
private courseId: string;
|
|
26
|
+
private filter: TagFilter;
|
|
27
|
+
private user: UserDBInterface;
|
|
28
|
+
|
|
29
|
+
// Cache resolved card IDs to avoid repeated lookups within a session
|
|
30
|
+
private resolvedCardIds: Set<string> | null = null;
|
|
31
|
+
|
|
32
|
+
constructor(courseId: string, filter: TagFilter, user: UserDBInterface) {
|
|
33
|
+
this.courseId = courseId;
|
|
34
|
+
this.filter = filter;
|
|
35
|
+
this.user = user;
|
|
36
|
+
|
|
37
|
+
logger.info(
|
|
38
|
+
`[TagFilteredContentSource] Created for course "${courseId}" with filter:`,
|
|
39
|
+
JSON.stringify(filter)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the TagFilter to a set of eligible card IDs.
|
|
45
|
+
*
|
|
46
|
+
* - Cards in `include` tags are OR'd together (card needs at least one)
|
|
47
|
+
* - Cards in `exclude` tags are removed from the result
|
|
48
|
+
*/
|
|
49
|
+
private async resolveFilteredCardIds(): Promise<Set<string>> {
|
|
50
|
+
// Return cached result if available
|
|
51
|
+
if (this.resolvedCardIds !== null) {
|
|
52
|
+
return this.resolvedCardIds;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const includedCardIds = new Set<string>();
|
|
56
|
+
|
|
57
|
+
// Build inclusion set (OR of all include tags)
|
|
58
|
+
if (this.filter.include.length > 0) {
|
|
59
|
+
for (const tagName of this.filter.include) {
|
|
60
|
+
try {
|
|
61
|
+
const tagDoc = await getTag(this.courseId, tagName);
|
|
62
|
+
tagDoc.taggedCards.forEach((cardId) => includedCardIds.add(cardId));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logger.warn(
|
|
65
|
+
`[TagFilteredContentSource] Could not resolve tag "${tagName}" for inclusion:`,
|
|
66
|
+
error
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If no include tags specified or none resolved, return empty set
|
|
73
|
+
// (requiring explicit inclusion prevents "study everything" on empty filter)
|
|
74
|
+
if (includedCardIds.size === 0 && this.filter.include.length > 0) {
|
|
75
|
+
logger.warn(
|
|
76
|
+
`[TagFilteredContentSource] No cards found for include tags: ${this.filter.include.join(', ')}`
|
|
77
|
+
);
|
|
78
|
+
this.resolvedCardIds = new Set();
|
|
79
|
+
return this.resolvedCardIds;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build exclusion set
|
|
83
|
+
const excludedCardIds = new Set<string>();
|
|
84
|
+
if (this.filter.exclude.length > 0) {
|
|
85
|
+
for (const tagName of this.filter.exclude) {
|
|
86
|
+
try {
|
|
87
|
+
const tagDoc = await getTag(this.courseId, tagName);
|
|
88
|
+
tagDoc.taggedCards.forEach((cardId) => excludedCardIds.add(cardId));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
logger.warn(
|
|
91
|
+
`[TagFilteredContentSource] Could not resolve tag "${tagName}" for exclusion:`,
|
|
92
|
+
error
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Apply exclusion filter
|
|
99
|
+
const finalCardIds = new Set<string>();
|
|
100
|
+
for (const cardId of includedCardIds) {
|
|
101
|
+
if (!excludedCardIds.has(cardId)) {
|
|
102
|
+
finalCardIds.add(cardId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
logger.info(
|
|
107
|
+
`[TagFilteredContentSource] Resolved ${finalCardIds.size} cards ` +
|
|
108
|
+
`(included: ${includedCardIds.size}, excluded: ${excludedCardIds.size})`
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
this.resolvedCardIds = finalCardIds;
|
|
112
|
+
return finalCardIds;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Gets new cards that match the tag filter and are not already active for the user.
|
|
117
|
+
*/
|
|
118
|
+
public async getNewCards(limit?: number): Promise<StudySessionNewItem[]> {
|
|
119
|
+
if (!hasActiveFilter(this.filter)) {
|
|
120
|
+
logger.warn('[TagFilteredContentSource] getNewCards called with no active filter');
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
125
|
+
const activeCards = await this.user.getActiveCards();
|
|
126
|
+
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
127
|
+
|
|
128
|
+
const newItems: StudySessionNewItem[] = [];
|
|
129
|
+
for (const cardId of eligibleCardIds) {
|
|
130
|
+
if (!activeCardIds.has(cardId)) {
|
|
131
|
+
newItems.push({
|
|
132
|
+
courseID: this.courseId,
|
|
133
|
+
cardID: cardId,
|
|
134
|
+
contentSourceType: 'course',
|
|
135
|
+
contentSourceID: this.courseId,
|
|
136
|
+
status: 'new',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (limit !== undefined && newItems.length >= limit) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
logger.info(`[TagFilteredContentSource] Found ${newItems.length} new cards matching filter`);
|
|
146
|
+
return newItems;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
151
|
+
*/
|
|
152
|
+
public async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
153
|
+
if (!hasActiveFilter(this.filter)) {
|
|
154
|
+
logger.warn('[TagFilteredContentSource] getPendingReviews called with no active filter');
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
159
|
+
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
160
|
+
|
|
161
|
+
const filteredReviews = allReviews.filter((review) => {
|
|
162
|
+
return eligibleCardIds.has(review.cardId);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
logger.info(
|
|
166
|
+
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter ` +
|
|
167
|
+
`(of ${allReviews.length} total)`
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return filteredReviews.map((r) => ({
|
|
171
|
+
...r,
|
|
172
|
+
courseID: r.courseId,
|
|
173
|
+
cardID: r.cardId,
|
|
174
|
+
contentSourceType: 'course' as const,
|
|
175
|
+
contentSourceID: this.courseId,
|
|
176
|
+
reviewID: r._id,
|
|
177
|
+
status: 'review' as const,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get cards with suitability scores for presentation.
|
|
183
|
+
*
|
|
184
|
+
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
185
|
+
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
186
|
+
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
187
|
+
*
|
|
188
|
+
* @param limit - Maximum number of cards to return
|
|
189
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
190
|
+
*/
|
|
191
|
+
public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
192
|
+
const [newCards, reviews] = await Promise.all([
|
|
193
|
+
this.getNewCards(limit),
|
|
194
|
+
this.getPendingReviews(),
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
const weighted: WeightedCard[] = [
|
|
198
|
+
...reviews.map((r) => ({
|
|
199
|
+
cardId: r.cardID,
|
|
200
|
+
courseId: r.courseID,
|
|
201
|
+
score: 1.0,
|
|
202
|
+
provenance: [
|
|
203
|
+
{
|
|
204
|
+
strategy: 'tagFilter',
|
|
205
|
+
strategyName: 'Tag Filter',
|
|
206
|
+
strategyId: 'TAG_FILTER',
|
|
207
|
+
action: 'generated' as const,
|
|
208
|
+
score: 1.0,
|
|
209
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(', ')})`,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
})),
|
|
213
|
+
...newCards.map((c) => ({
|
|
214
|
+
cardId: c.cardID,
|
|
215
|
+
courseId: c.courseID,
|
|
216
|
+
score: 1.0,
|
|
217
|
+
provenance: [
|
|
218
|
+
{
|
|
219
|
+
strategy: 'tagFilter',
|
|
220
|
+
strategyName: 'Tag Filter',
|
|
221
|
+
strategyId: 'TAG_FILTER',
|
|
222
|
+
action: 'generated' as const,
|
|
223
|
+
score: 1.0,
|
|
224
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(', ')})`,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
})),
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
// Reviews first, then new cards; respect limit
|
|
231
|
+
return weighted.slice(0, limit);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Clears the cached resolved card IDs.
|
|
236
|
+
* Call this if the underlying tag data may have changed during a session.
|
|
237
|
+
*/
|
|
238
|
+
public clearCache(): void {
|
|
239
|
+
this.resolvedCardIds = null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Returns the course ID this source is filtering.
|
|
244
|
+
*/
|
|
245
|
+
public getCourseId(): string {
|
|
246
|
+
return this.courseId;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Returns the active tag filter.
|
|
251
|
+
*/
|
|
252
|
+
public getFilter(): TagFilter {
|
|
253
|
+
return this.filter;
|
|
254
|
+
}
|
|
255
|
+
}
|
package/src/study/index.ts
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
// Test suite for cross-platform data directory utilities
|
|
2
|
-
import { describe, it, expect,
|
|
3
|
-
import * as os from 'os';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
3
|
import * as path from 'path';
|
|
5
|
-
import { getAppDataDirectory, getDbPath, ensureAppDataDirectory } from './dataDirectory';
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
const originalHomedir = os.homedir;
|
|
9
|
-
const testHome = '/test/home';
|
|
5
|
+
const testHome = '/test/home';
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
// Mock os module for ESM compatibility
|
|
8
|
+
vi.mock('os', () => ({
|
|
9
|
+
homedir: () => testHome
|
|
10
|
+
}));
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
// Mock fs module to prevent actual file system operations
|
|
13
|
+
vi.mock('fs', () => ({
|
|
14
|
+
promises: {
|
|
15
|
+
mkdir: vi.fn().mockResolvedValue(undefined)
|
|
16
|
+
}
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { getAppDataDirectory, getDbPath, ensureAppDataDirectory } from './dataDirectory';
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
|
|
22
|
+
// Get reference to the mocked function after import
|
|
23
|
+
const mockMkdir = vi.mocked(fs.promises.mkdir);
|
|
24
|
+
|
|
25
|
+
describe('dataDirectory utilities', () => {
|
|
20
26
|
|
|
21
27
|
describe('getAppDataDirectory', () => {
|
|
22
28
|
it('should return correct path using home directory', () => {
|
|
@@ -40,14 +46,37 @@ describe('dataDirectory utilities', () => {
|
|
|
40
46
|
});
|
|
41
47
|
|
|
42
48
|
describe('ensureAppDataDirectory', () => {
|
|
43
|
-
it('should
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
it('should create directory with correct path and options', async () => {
|
|
50
|
+
mockMkdir.mockClear();
|
|
51
|
+
|
|
52
|
+
const result = await ensureAppDataDirectory();
|
|
53
|
+
|
|
54
|
+
expect(result).toBe(path.join(testHome, '.tuilder'));
|
|
55
|
+
expect(mockMkdir).toHaveBeenCalledWith(
|
|
56
|
+
path.join(testHome, '.tuilder'),
|
|
57
|
+
{ recursive: true }
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should handle EEXIST errors gracefully', async () => {
|
|
62
|
+
mockMkdir.mockClear();
|
|
63
|
+
mockMkdir.mockRejectedValueOnce({ code: 'EEXIST' });
|
|
64
|
+
|
|
65
|
+
const result = await ensureAppDataDirectory();
|
|
66
|
+
|
|
67
|
+
expect(result).toBe(path.join(testHome, '.tuilder'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should throw on other filesystem errors', async () => {
|
|
71
|
+
mockMkdir.mockClear();
|
|
72
|
+
mockMkdir.mockRejectedValueOnce({
|
|
73
|
+
code: 'EACCES',
|
|
74
|
+
message: 'permission denied'
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await expect(ensureAppDataDirectory()).rejects.toThrow(
|
|
78
|
+
'Failed to create app data directory'
|
|
79
|
+
);
|
|
51
80
|
});
|
|
52
81
|
});
|
|
53
82
|
});
|