@open330/kiwimu 0.7.1 → 1.1.0

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/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { normalizeTitle } from "./utils";
2
3
 
3
4
  export interface Source {
4
5
  id: number;
@@ -16,8 +17,12 @@ export interface Page {
16
17
  content: string;
17
18
  source_id: number | null;
18
19
  section_anchor: string | null;
19
- page_type: string; // 'source' | 'concept'
20
+ page_type: 'source' | 'concept';
20
21
  display_order: number;
22
+ origin: string; // 'batch' | 'user'
23
+ user_question: string | null;
24
+ parent_page_id: number | null;
25
+ category: string | null;
21
26
  }
22
27
 
23
28
  export interface SourceMeta {
@@ -34,17 +39,54 @@ export interface Link {
34
39
  anchor_text: string;
35
40
  }
36
41
 
42
+ export interface Citation {
43
+ id: number;
44
+ page_id: number;
45
+ source_id: number;
46
+ source_page_id: number | null;
47
+ excerpt: string | null;
48
+ context: string | null;
49
+ created_at: string;
50
+ // Joined fields (optional, populated by queries)
51
+ source_title?: string;
52
+ source_page_title?: string;
53
+ source_page_slug?: string;
54
+ page_title?: string;
55
+ page_slug?: string;
56
+ }
57
+
37
58
  export interface Quiz {
38
59
  id: number;
39
60
  page_id: number;
40
61
  question: string;
41
62
  answer: string;
63
+ explanation: string;
42
64
  quiz_type: string; // 'fill_blank' | 'ox' | 'short_answer'
65
+ ease_factor: number;
66
+ interval: number;
67
+ next_review_at: string | null;
43
68
  created_at: string;
44
69
  page_title?: string;
45
70
  page_slug?: string;
46
71
  }
47
72
 
73
+ export interface ActivityLogEntry {
74
+ id: number;
75
+ action: string;
76
+ entity_type: string | null;
77
+ entity_id: number | null;
78
+ title: string;
79
+ details: string | null;
80
+ created_at: string;
81
+ }
82
+
83
+ export interface SourceCoverage {
84
+ sourceId: number;
85
+ sourceTitle: string;
86
+ citationCount: number;
87
+ pageCount: number;
88
+ }
89
+
48
90
  const SCHEMA = `
49
91
  CREATE TABLE IF NOT EXISTS sources (
50
92
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -59,10 +101,14 @@ CREATE TABLE IF NOT EXISTS pages (
59
101
  slug TEXT UNIQUE NOT NULL,
60
102
  title TEXT NOT NULL,
61
103
  content TEXT NOT NULL,
62
- source_id INTEGER REFERENCES sources(id),
104
+ source_id INTEGER REFERENCES sources(id) ON DELETE CASCADE,
63
105
  section_anchor TEXT,
64
106
  page_type TEXT NOT NULL DEFAULT 'concept',
65
107
  display_order INTEGER NOT NULL DEFAULT 0,
108
+ origin TEXT NOT NULL DEFAULT 'batch',
109
+ user_question TEXT DEFAULT NULL,
110
+ parent_page_id INTEGER DEFAULT NULL,
111
+ category TEXT DEFAULT NULL,
66
112
  created_at TEXT DEFAULT (datetime('now')),
67
113
  updated_at TEXT DEFAULT (datetime('now'))
68
114
  );
@@ -77,8 +123,8 @@ CREATE TABLE IF NOT EXISTS usage_logs (
77
123
  created_at TEXT DEFAULT (datetime('now'))
78
124
  );
79
125
  CREATE TABLE IF NOT EXISTS links (
80
- from_page_id INTEGER REFERENCES pages(id),
81
- to_page_id INTEGER REFERENCES pages(id),
126
+ from_page_id INTEGER REFERENCES pages(id) ON DELETE CASCADE,
127
+ to_page_id INTEGER REFERENCES pages(id) ON DELETE CASCADE,
82
128
  anchor_text TEXT,
83
129
  PRIMARY KEY (from_page_id, to_page_id, anchor_text)
84
130
  );
@@ -87,15 +133,85 @@ CREATE TABLE IF NOT EXISTS quizzes (
87
133
  page_id INTEGER NOT NULL,
88
134
  question TEXT NOT NULL,
89
135
  answer TEXT NOT NULL,
136
+ explanation TEXT DEFAULT '',
90
137
  quiz_type TEXT NOT NULL DEFAULT 'fill_blank',
138
+ ease_factor REAL NOT NULL DEFAULT 2.5,
139
+ interval INTEGER NOT NULL DEFAULT 0,
140
+ next_review_at TEXT DEFAULT NULL,
91
141
  created_at TEXT DEFAULT (datetime('now')),
92
- FOREIGN KEY (page_id) REFERENCES pages(id)
142
+ FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE
143
+ );
144
+ CREATE TABLE IF NOT EXISTS quiz_attempts (
145
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
146
+ quiz_id INTEGER NOT NULL,
147
+ is_correct INTEGER NOT NULL DEFAULT 0,
148
+ attempted_at TEXT DEFAULT (datetime('now')),
149
+ FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE
93
150
  );
94
151
  CREATE INDEX IF NOT EXISTS idx_pages_source_id ON pages(source_id);
152
+ CREATE INDEX IF NOT EXISTS idx_attempts_quiz_id ON quiz_attempts(quiz_id);
95
153
  CREATE INDEX IF NOT EXISTS idx_pages_page_type ON pages(page_type);
96
154
  CREATE INDEX IF NOT EXISTS idx_links_to_page ON links(to_page_id);
97
155
  CREATE INDEX IF NOT EXISTS idx_links_from_page ON links(from_page_id);
98
156
  CREATE INDEX IF NOT EXISTS idx_quizzes_page_id ON quizzes(page_id);
157
+ CREATE INDEX IF NOT EXISTS idx_pages_origin ON pages(origin);
158
+ CREATE INDEX IF NOT EXISTS idx_pages_parent ON pages(parent_page_id);
159
+ CREATE INDEX IF NOT EXISTS idx_pages_category ON pages(category);
160
+ CREATE TABLE IF NOT EXISTS pipeline_checkpoints (
161
+ source_id INTEGER NOT NULL,
162
+ phase TEXT NOT NULL,
163
+ batch_index INTEGER NOT NULL DEFAULT 0,
164
+ status TEXT NOT NULL DEFAULT 'completed',
165
+ created_at TEXT DEFAULT (datetime('now')),
166
+ PRIMARY KEY (source_id, phase, batch_index)
167
+ );
168
+ CREATE TABLE IF NOT EXISTS page_embeddings (
169
+ page_id INTEGER PRIMARY KEY,
170
+ embedding BLOB NOT NULL,
171
+ model TEXT NOT NULL DEFAULT 'text-embedding-3-small',
172
+ updated_at TEXT DEFAULT (datetime('now')),
173
+ FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE
174
+ );
175
+ CREATE TABLE IF NOT EXISTS activity_log (
176
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
177
+ action TEXT NOT NULL,
178
+ entity_type TEXT,
179
+ entity_id INTEGER,
180
+ title TEXT,
181
+ details TEXT,
182
+ created_at TEXT DEFAULT (datetime('now'))
183
+ );
184
+ CREATE INDEX IF NOT EXISTS idx_activity_log_action ON activity_log(action);
185
+ CREATE INDEX IF NOT EXISTS idx_activity_log_created ON activity_log(created_at);
186
+ CREATE TABLE IF NOT EXISTS citations (
187
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188
+ page_id INTEGER NOT NULL,
189
+ source_id INTEGER NOT NULL,
190
+ source_page_id INTEGER,
191
+ excerpt TEXT,
192
+ context TEXT,
193
+ created_at TEXT DEFAULT (datetime('now')),
194
+ FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE,
195
+ FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE,
196
+ FOREIGN KEY (source_page_id) REFERENCES pages(id) ON DELETE SET NULL
197
+ );
198
+ CREATE INDEX IF NOT EXISTS idx_citations_page ON citations(page_id);
199
+ CREATE INDEX IF NOT EXISTS idx_citations_source ON citations(source_id);
200
+ `;
201
+
202
+ const FTS_SCHEMA = `
203
+ CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(title, content, content=pages, content_rowid=id);
204
+
205
+ CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
206
+ INSERT INTO pages_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
207
+ END;
208
+ CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
209
+ INSERT INTO pages_fts(pages_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content);
210
+ END;
211
+ CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN
212
+ INSERT INTO pages_fts(pages_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content);
213
+ INSERT INTO pages_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
214
+ END;
99
215
  `;
100
216
 
101
217
  export class Store {
@@ -103,12 +219,39 @@ export class Store {
103
219
 
104
220
  constructor(dbPath: string) {
105
221
  this.db = new Database(dbPath);
222
+ this.db.exec("PRAGMA busy_timeout = 30000");
106
223
  this.db.exec("PRAGMA journal_mode=WAL");
107
224
  this.db.exec("PRAGMA foreign_keys=ON");
225
+ this.initSchema();
108
226
  }
109
227
 
110
228
  initSchema(): void {
111
229
  this.db.exec(SCHEMA);
230
+ // Migrate: add explanation column if missing (for existing databases)
231
+ try {
232
+ this.db.exec("ALTER TABLE quizzes ADD COLUMN explanation TEXT DEFAULT ''");
233
+ } catch {
234
+ // Column already exists — ignore
235
+ }
236
+ try { this.db.exec("ALTER TABLE pages ADD COLUMN origin TEXT NOT NULL DEFAULT 'batch'"); } catch {}
237
+ try { this.db.exec("ALTER TABLE pages ADD COLUMN user_question TEXT DEFAULT NULL"); } catch {}
238
+ try { this.db.exec("ALTER TABLE pages ADD COLUMN parent_page_id INTEGER DEFAULT NULL"); } catch {}
239
+ try { this.db.exec("ALTER TABLE pages ADD COLUMN category TEXT DEFAULT NULL"); } catch {}
240
+ // SM-2 spaced repetition columns
241
+ try { this.db.exec("ALTER TABLE quizzes ADD COLUMN ease_factor REAL NOT NULL DEFAULT 2.5"); } catch {}
242
+ try { this.db.exec("ALTER TABLE quizzes ADD COLUMN interval INTEGER NOT NULL DEFAULT 0"); } catch {}
243
+ try { this.db.exec("ALTER TABLE quizzes ADD COLUMN next_review_at TEXT DEFAULT NULL"); } catch {}
244
+ // FTS5 full-text search (may not be available in older SQLite builds)
245
+ try { this.db.exec(FTS_SCHEMA); } catch {}
246
+ this.rebuildFtsIndex();
247
+ }
248
+
249
+ rebuildFtsIndex(): void {
250
+ try {
251
+ this.db.exec("INSERT INTO pages_fts(pages_fts) VALUES('rebuild')");
252
+ } catch {
253
+ // FTS table might not exist yet in old databases
254
+ }
112
255
  }
113
256
 
114
257
  close(): void {
@@ -136,6 +279,10 @@ export class Store {
136
279
  return (this.db.prepare("SELECT * FROM sources WHERE uri = ?").get(uri) as Source) ?? null;
137
280
  }
138
281
 
282
+ countSources(): number {
283
+ return (this.db.prepare("SELECT COUNT(*) as count FROM sources").get() as any).count;
284
+ }
285
+
139
286
  listSources(): Source[] {
140
287
  return this.db.prepare("SELECT * FROM sources ORDER BY fetched_at DESC").all() as Source[];
141
288
  }
@@ -152,12 +299,21 @@ export class Store {
152
299
  content: string,
153
300
  sourceId?: number,
154
301
  sectionAnchor?: string,
155
- pageType: string = "concept",
302
+ pageType: 'source' | 'concept' = "concept",
156
303
  displayOrder: number = 0
157
304
  ): Page {
158
305
  this.db
159
306
  .prepare(
160
- "INSERT OR REPLACE INTO pages (slug, title, content, source_id, section_anchor, page_type, display_order) VALUES (?, ?, ?, ?, ?, ?, ?)"
307
+ `INSERT INTO pages (slug, title, content, source_id, section_anchor, page_type, display_order)
308
+ VALUES (?, ?, ?, ?, ?, ?, ?)
309
+ ON CONFLICT(slug) DO UPDATE SET
310
+ title = excluded.title,
311
+ content = excluded.content,
312
+ source_id = excluded.source_id,
313
+ section_anchor = excluded.section_anchor,
314
+ page_type = excluded.page_type,
315
+ display_order = excluded.display_order,
316
+ updated_at = datetime('now')`
161
317
  )
162
318
  .run(slug, title, content, sourceId ?? null, sectionAnchor ?? null, pageType, displayOrder);
163
319
  return this.db.prepare("SELECT * FROM pages WHERE slug = ?").get(slug) as Page;
@@ -167,6 +323,10 @@ export class Store {
167
323
  return (this.db.prepare("SELECT * FROM pages WHERE slug = ?").get(slug) as Page) ?? null;
168
324
  }
169
325
 
326
+ countPages(): number {
327
+ return (this.db.prepare("SELECT COUNT(*) as count FROM pages").get() as any).count;
328
+ }
329
+
170
330
  listPages(): Page[] {
171
331
  return this.db.prepare("SELECT * FROM pages ORDER BY title").all() as Page[];
172
332
  }
@@ -184,7 +344,11 @@ export class Store {
184
344
  }
185
345
 
186
346
  deletePagesBySource(sourceId: number): void {
187
- // Delete quizzes for these pages first
347
+ // Delete quiz attempts for quizzes on these pages first
348
+ this.db.prepare(
349
+ "DELETE FROM quiz_attempts WHERE quiz_id IN (SELECT id FROM quizzes WHERE page_id IN (SELECT id FROM pages WHERE source_id = ?))"
350
+ ).run(sourceId);
351
+ // Delete quizzes for these pages
188
352
  this.db.prepare(
189
353
  "DELETE FROM quizzes WHERE page_id IN (SELECT id FROM pages WHERE source_id = ?)"
190
354
  ).run(sourceId);
@@ -192,12 +356,18 @@ export class Store {
192
356
  this.db.prepare(
193
357
  "DELETE FROM links WHERE from_page_id IN (SELECT id FROM pages WHERE source_id = ?) OR to_page_id IN (SELECT id FROM pages WHERE source_id = ?)"
194
358
  ).run(sourceId, sourceId);
359
+ // Delete citations involving these pages
360
+ this.db.prepare(
361
+ "DELETE FROM citations WHERE page_id IN (SELECT id FROM pages WHERE source_id = ?) OR source_id = ?"
362
+ ).run(sourceId, sourceId);
195
363
  this.db.prepare("DELETE FROM pages WHERE source_id = ?").run(sourceId);
196
364
  }
197
365
 
198
366
  deleteAllPages(): void {
367
+ this.db.exec("DELETE FROM quiz_attempts");
199
368
  this.db.exec("DELETE FROM quizzes");
200
369
  this.db.exec("DELETE FROM links");
370
+ this.db.exec("DELETE FROM citations");
201
371
  this.db.exec("DELETE FROM pages");
202
372
  }
203
373
 
@@ -210,6 +380,39 @@ export class Store {
210
380
  this.db.prepare("UPDATE pages SET content = ?, updated_at = datetime('now') WHERE id = ?").run(content, pageId);
211
381
  }
212
382
 
383
+ updatePageContentBySlug(slug: string, content: string): void {
384
+ this.db.prepare("UPDATE pages SET content = ?, updated_at = datetime('now') WHERE slug = ?").run(content, slug);
385
+ }
386
+
387
+ updatePageCategory(pageId: number, category: string): void {
388
+ this.db.prepare("UPDATE pages SET category = ? WHERE id = ?").run(category, pageId);
389
+ }
390
+
391
+ listPagesByCategory(category: string): Page[] {
392
+ return this.db.prepare("SELECT * FROM pages WHERE category = ? ORDER BY title").all(category) as Page[];
393
+ }
394
+
395
+ listCategories(): Array<{ category: string; count: number }> {
396
+ return this.db.prepare(
397
+ "SELECT category, COUNT(*) as count FROM pages WHERE category IS NOT NULL GROUP BY category ORDER BY category"
398
+ ).all() as Array<{ category: string; count: number }>;
399
+ }
400
+
401
+ addDynamicPage(slug: string, title: string, content: string, parentPageId: number, userQuestion: string): number {
402
+ this.db.prepare(
403
+ "INSERT INTO pages (slug, title, content, source_id, page_type, origin, user_question, parent_page_id) VALUES (?, ?, ?, NULL, 'concept', 'user', ?, ?)"
404
+ ).run(slug, title, content, userQuestion, parentPageId);
405
+ return (this.db.prepare("SELECT id FROM pages WHERE slug = ?").get(slug) as any).id;
406
+ }
407
+
408
+ listDynamicPages(): Page[] {
409
+ return this.db.prepare("SELECT * FROM pages WHERE origin = 'user' ORDER BY id DESC").all() as Page[];
410
+ }
411
+
412
+ getDynamicPagesByParent(parentPageId: number): Page[] {
413
+ return this.db.prepare("SELECT * FROM pages WHERE parent_page_id = ? ORDER BY id DESC").all(parentPageId) as Page[];
414
+ }
415
+
213
416
  // --- Links ---
214
417
 
215
418
  addLink(fromId: number, toId: number, anchorText: string): void {
@@ -230,19 +433,31 @@ export class Store {
230
433
  .all(pageId) as Page[];
231
434
  }
232
435
 
436
+ getForwardLinks(pageId: number): Page[] {
437
+ return this.db
438
+ .prepare(
439
+ `SELECT p.* FROM pages p JOIN links l ON l.to_page_id = p.id WHERE l.from_page_id = ? ORDER BY p.title LIMIT 10`
440
+ )
441
+ .all(pageId) as Page[];
442
+ }
443
+
233
444
  getAllLinks(): Link[] {
234
445
  return this.db.prepare("SELECT * FROM links").all() as Link[];
235
446
  }
236
447
 
237
- getAllBacklinksGrouped(): Map<number, Array<{id: number; slug: string; title: string; page_type: string}>> {
448
+ countLinks(): number {
449
+ return (this.db.prepare("SELECT COUNT(*) as c FROM links").get() as any).c;
450
+ }
451
+
452
+ getAllBacklinksGrouped(): Map<number, Array<{id: number; slug: string; title: string; page_type: 'source' | 'concept'}>> {
238
453
  const rows = this.db.prepare(`
239
454
  SELECT l.to_page_id, p.id, p.slug, p.title, p.page_type
240
455
  FROM links l
241
456
  JOIN pages p ON p.id = l.from_page_id
242
457
  ORDER BY l.to_page_id
243
- `).all() as Array<{to_page_id: number; id: number; slug: string; title: string; page_type: string}>;
458
+ `).all() as Array<{to_page_id: number; id: number; slug: string; title: string; page_type: 'source' | 'concept'}>;
244
459
 
245
- const map = new Map<number, Array<{id: number; slug: string; title: string; page_type: string}>>();
460
+ const map = new Map<number, Array<{id: number; slug: string; title: string; page_type: 'source' | 'concept'}>>();
246
461
  for (const row of rows) {
247
462
  if (!map.has(row.to_page_id)) map.set(row.to_page_id, []);
248
463
  map.get(row.to_page_id)!.push({ id: row.id, slug: row.slug, title: row.title, page_type: row.page_type });
@@ -252,10 +467,10 @@ export class Store {
252
467
 
253
468
  // --- Quizzes ---
254
469
 
255
- addQuiz(pageId: number, question: string, answer: string, quizType: string): void {
470
+ addQuiz(pageId: number, question: string, answer: string, quizType: string, explanation: string = ""): void {
256
471
  this.db
257
- .prepare("INSERT INTO quizzes (page_id, question, answer, quiz_type) VALUES (?, ?, ?, ?)")
258
- .run(pageId, question, answer, quizType);
472
+ .prepare("INSERT INTO quizzes (page_id, question, answer, explanation, quiz_type) VALUES (?, ?, ?, ?, ?)")
473
+ .run(pageId, question, answer, explanation, quizType);
259
474
  }
260
475
 
261
476
  getQuizzesByPage(pageId: number): Quiz[] {
@@ -292,9 +507,141 @@ export class Store {
292
507
  this.db.prepare("DELETE FROM quizzes WHERE page_id = ?").run(pageId);
293
508
  }
294
509
 
510
+ getSmartQuizzes(count: number): Quiz[] {
511
+ const now = new Date().toISOString();
512
+ return this.db.prepare(`
513
+ SELECT q.*, p.title as page_title, p.slug as page_slug
514
+ FROM quizzes q
515
+ JOIN pages p ON p.id = q.page_id
516
+ WHERE q.next_review_at IS NULL OR q.next_review_at <= ?
517
+ ORDER BY
518
+ CASE WHEN q.next_review_at IS NULL THEN 0 ELSE 1 END,
519
+ q.next_review_at ASC
520
+ LIMIT ?
521
+ `).all(now, count) as Quiz[];
522
+ }
523
+
524
+ // --- SM-2 Spaced Repetition ---
525
+
526
+ updateQuizSRS(quizId: number, quality: number): void {
527
+ // quality: 0-5 (0=total blackout, 5=perfect)
528
+ const quiz = this.db.prepare("SELECT ease_factor, interval FROM quizzes WHERE id = ?").get(quizId) as any;
529
+ if (!quiz) return;
530
+
531
+ let ef = quiz.ease_factor;
532
+ let interval = quiz.interval;
533
+
534
+ if (quality >= 3) {
535
+ // Correct answer
536
+ if (interval === 0) interval = 1;
537
+ else if (interval === 1) interval = 6;
538
+ else interval = Math.round(interval * ef);
539
+
540
+ ef = ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
541
+ } else {
542
+ // Wrong answer - reset
543
+ interval = 0;
544
+ // ef stays the same
545
+ }
546
+
547
+ if (ef < 1.3) ef = 1.3;
548
+
549
+ const nextReview = new Date();
550
+ nextReview.setDate(nextReview.getDate() + interval);
551
+
552
+ this.db.prepare(
553
+ "UPDATE quizzes SET ease_factor = ?, interval = ?, next_review_at = ? WHERE id = ?"
554
+ ).run(ef, interval, nextReview.toISOString(), quizId);
555
+ }
556
+
557
+ getLearningStats(): { total: number; mastered: number; learning: number; new: number; dueToday: number } {
558
+ const now = new Date().toISOString();
559
+ const total = (this.db.prepare("SELECT COUNT(*) as c FROM quizzes").get() as any).c;
560
+ const mastered = (this.db.prepare("SELECT COUNT(*) as c FROM quizzes WHERE interval >= 21").get() as any).c;
561
+ const newQ = (this.db.prepare("SELECT COUNT(*) as c FROM quizzes WHERE next_review_at IS NULL").get() as any).c;
562
+ const due = (this.db.prepare("SELECT COUNT(*) as c FROM quizzes WHERE next_review_at <= ?").get(now) as any).c;
563
+ return { total, mastered, learning: total - mastered - newQ, new: newQ, dueToday: due };
564
+ }
565
+
566
+ getWeakConcepts(limit: number): Array<{title: string; slug: string; wrongCount: number}> {
567
+ return this.db.prepare(`
568
+ SELECT p.title, p.slug, COUNT(qa.id) as wrongCount
569
+ FROM quiz_attempts qa
570
+ JOIN quizzes q ON q.id = qa.quiz_id
571
+ JOIN pages p ON p.id = q.page_id
572
+ WHERE qa.is_correct = 0
573
+ GROUP BY p.id
574
+ ORDER BY wrongCount DESC
575
+ LIMIT ?
576
+ `).all(limit) as any[];
577
+ }
578
+
579
+ // --- Quiz Attempts ---
580
+
581
+ addQuizAttempt(quizId: number, isCorrect: boolean): void {
582
+ this.db
583
+ .prepare("INSERT INTO quiz_attempts (quiz_id, is_correct) VALUES (?, ?)")
584
+ .run(quizId, isCorrect ? 1 : 0);
585
+ const quiz = this.db.prepare("SELECT question FROM quizzes WHERE id = ?").get(quizId) as { question: string } | undefined;
586
+ if (quiz) {
587
+ this.addActivityLog('quiz_attempted', `Answered quiz: ${quiz.question.slice(0, 80)}`, 'quiz', quizId, { is_correct: isCorrect });
588
+ }
589
+ }
590
+
591
+ getQuizStats(): { total: number; correct: number; incorrect: number; unattempted: number } {
592
+ const totalQuizzes = (this.db.prepare("SELECT COUNT(*) as cnt FROM quizzes").get() as { cnt: number }).cnt;
593
+ const attemptRow = this.db.prepare(`
594
+ SELECT COUNT(*) as total,
595
+ SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END) as correct,
596
+ SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as incorrect
597
+ FROM quiz_attempts
598
+ `).get() as { total: number; correct: number; incorrect: number };
599
+ const attemptedQuizzes = (this.db.prepare("SELECT COUNT(DISTINCT quiz_id) as cnt FROM quiz_attempts").get() as { cnt: number }).cnt;
600
+ return {
601
+ total: attemptRow.total,
602
+ correct: attemptRow.correct,
603
+ incorrect: attemptRow.incorrect,
604
+ unattempted: totalQuizzes - attemptedQuizzes,
605
+ };
606
+ }
607
+
608
+ getWeakQuizzes(limit: number): Quiz[] {
609
+ return this.db.prepare(`
610
+ SELECT q.*, p.title as page_title, p.slug as page_slug
611
+ FROM quizzes q
612
+ JOIN pages p ON p.id = q.page_id
613
+ LEFT JOIN (
614
+ SELECT quiz_id,
615
+ SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as wrong_count,
616
+ COUNT(*) as attempt_count
617
+ FROM quiz_attempts
618
+ GROUP BY quiz_id
619
+ ) a ON a.quiz_id = q.id
620
+ ORDER BY
621
+ CASE WHEN a.attempt_count IS NULL THEN 1 ELSE 0 END DESC,
622
+ COALESCE(a.wrong_count, 0) DESC
623
+ LIMIT ?
624
+ `).all(limit) as Quiz[];
625
+ }
626
+
627
+ getQuizHistory(limit: number): Array<{ quiz_id: number; question: string; is_correct: boolean; attempted_at: string }> {
628
+ return this.db.prepare(`
629
+ SELECT qa.quiz_id, q.question, qa.is_correct, qa.attempted_at
630
+ FROM quiz_attempts qa
631
+ JOIN quizzes q ON q.id = qa.quiz_id
632
+ ORDER BY qa.attempted_at DESC
633
+ LIMIT ?
634
+ `).all(limit).map((row: any) => ({
635
+ quiz_id: row.quiz_id,
636
+ question: row.question,
637
+ is_correct: row.is_correct === 1,
638
+ attempted_at: row.attempted_at,
639
+ }));
640
+ }
641
+
295
642
  // --- Usage ---
296
643
 
297
- addUsageLog(sourceId: number, calls: number, prompt: number, completion: number, total: number, cost: number): void {
644
+ addUsageLog(sourceId: number | null, calls: number, prompt: number, completion: number, total: number, cost: number): void {
298
645
  this.db
299
646
  .prepare("INSERT INTO usage_logs (source_id, llm_calls, prompt_tokens, completion_tokens, total_tokens, estimated_cost_usd) VALUES (?, ?, ?, ?, ?, ?)")
300
647
  .run(sourceId, calls, prompt, completion, total, cost);
@@ -306,4 +653,294 @@ export class Store {
306
653
  ).get() as { totalCalls: number; promptTokens: number; completionTokens: number; totalTokens: number; totalCost: number };
307
654
  return row;
308
655
  }
656
+
657
+ // --- Pipeline Checkpoints ---
658
+
659
+ setCheckpoint(sourceId: number, phase: string, batchIndex: number = 0): void {
660
+ this.db.prepare(
661
+ "INSERT OR REPLACE INTO pipeline_checkpoints (source_id, phase, batch_index, status) VALUES (?, ?, ?, 'completed')"
662
+ ).run(sourceId, phase, batchIndex);
663
+ }
664
+
665
+ getLastCompletedBatch(sourceId: number, phase: string): number {
666
+ const row = this.db.prepare(
667
+ "SELECT MAX(batch_index) as last_batch FROM pipeline_checkpoints WHERE source_id = ? AND phase = ? AND status = 'completed'"
668
+ ).get(sourceId, phase) as { last_batch: number | null } | undefined;
669
+ return row?.last_batch ?? -1;
670
+ }
671
+
672
+ hasPhaseCheckpoint(sourceId: number, phase: string): boolean {
673
+ const row = this.db.prepare(
674
+ "SELECT COUNT(*) as cnt FROM pipeline_checkpoints WHERE source_id = ? AND phase = ? AND status = 'completed'"
675
+ ).get(sourceId, phase) as { cnt: number };
676
+ return row.cnt > 0;
677
+ }
678
+
679
+ hasCheckpoints(sourceId: number): boolean {
680
+ const row = this.db.prepare(
681
+ "SELECT COUNT(*) as cnt FROM pipeline_checkpoints WHERE source_id = ?"
682
+ ).get(sourceId) as { cnt: number };
683
+ return row.cnt > 0;
684
+ }
685
+
686
+ clearCheckpoints(sourceId: number): void {
687
+ this.db.prepare("DELETE FROM pipeline_checkpoints WHERE source_id = ?").run(sourceId);
688
+ }
689
+
690
+ searchPages(query: string, limit: number = 5): Array<{slug: string; title: string; page_type: 'source' | 'concept'; origin: string; preview: string; rank: number}> {
691
+ try {
692
+ // Try FTS5 search first (much better relevance)
693
+ const ftsQuery = query.split(/\s+/).filter(w => w.length >= 2).map(w => `"${w.replace(/"/g, "")}"`)
694
+ .join(' OR ');
695
+ if (!ftsQuery) return [];
696
+
697
+ return this.db.prepare(`
698
+ SELECT p.slug, p.title, p.page_type, p.origin,
699
+ substr(p.content, 1, 200) as preview,
700
+ rank
701
+ FROM pages_fts f
702
+ JOIN pages p ON p.id = f.rowid
703
+ WHERE pages_fts MATCH ?
704
+ ORDER BY rank
705
+ LIMIT ?
706
+ `).all(ftsQuery, limit) as any[];
707
+ } catch {
708
+ // Fallback to LIKE search if FTS not available
709
+ const words = query.split(/\s+/).filter(w => w.length >= 2);
710
+ if (!words.length) return [];
711
+ const conditions = words.map(() => "(title LIKE ? OR content LIKE ?)").join(" OR ");
712
+ const params = words.flatMap(w => [`%${w}%`, `%${w}%`]);
713
+ return this.db.prepare(`
714
+ SELECT slug, title, page_type, origin, substr(content, 1, 200) as preview, 0 as rank
715
+ FROM pages WHERE ${conditions}
716
+ ORDER BY CASE WHEN title LIKE ? THEN 0 ELSE 1 END
717
+ LIMIT ?
718
+ `).all(...params, `%${query}%`, limit) as any[];
719
+ }
720
+ }
721
+
722
+ // --- Embeddings ---
723
+
724
+ saveEmbedding(pageId: number, embedding: Float32Array, model: string): void {
725
+ const buffer = Buffer.from(embedding.buffer);
726
+ this.db.prepare(
727
+ "INSERT OR REPLACE INTO page_embeddings (page_id, embedding, model) VALUES (?, ?, ?)"
728
+ ).run(pageId, buffer, model);
729
+ }
730
+
731
+ getEmbedding(pageId: number): Float32Array | null {
732
+ const row = this.db.prepare("SELECT embedding FROM page_embeddings WHERE page_id = ?").get(pageId) as any;
733
+ if (!row) return null;
734
+ return new Float32Array(row.embedding.buffer);
735
+ }
736
+
737
+ getAllEmbeddings(): Array<{pageId: number; slug: string; title: string; pageType: string; origin: string; embedding: Float32Array}> {
738
+ const rows = this.db.prepare(`
739
+ SELECT e.page_id, e.embedding, p.slug, p.title, p.page_type, p.origin
740
+ FROM page_embeddings e
741
+ JOIN pages p ON p.id = e.page_id
742
+ `).all() as any[];
743
+ return rows.map(r => ({
744
+ pageId: r.page_id,
745
+ slug: r.slug,
746
+ title: r.title,
747
+ pageType: r.page_type,
748
+ origin: r.origin,
749
+ embedding: new Float32Array(r.embedding.buffer)
750
+ }));
751
+ }
752
+
753
+ getPagesWithoutEmbeddings(): Array<{id: number; title: string; content: string}> {
754
+ return this.db.prepare(`
755
+ SELECT p.id, p.title, p.content
756
+ FROM pages p
757
+ LEFT JOIN page_embeddings e ON e.page_id = p.id
758
+ WHERE e.page_id IS NULL
759
+ `).all() as any[];
760
+ }
761
+
762
+ /** Find a page with a very similar title (normalized: lowercase, trimmed, no punctuation) */
763
+ findSimilarPage(title: string): Page | null {
764
+ const normalized = normalizeTitle(title);
765
+ if (!normalized) return null;
766
+
767
+ const rows = this.db.prepare("SELECT id, slug, title FROM pages WHERE page_type = 'concept'")
768
+ .all() as Array<{ id: number; slug: string; title: string }>;
769
+ for (const row of rows) {
770
+ if (normalizeTitle(row.title) === normalized) {
771
+ // Fetch the full page only for the match
772
+ return this.getPage(row.slug);
773
+ }
774
+ }
775
+ return null;
776
+ }
777
+
778
+ getSourcePages(sourceId: number): Page[] {
779
+ return this.db.prepare(
780
+ "SELECT * FROM pages WHERE source_id = ? AND page_type = 'source' ORDER BY display_order"
781
+ ).all(sourceId) as Page[];
782
+ }
783
+
784
+ // --- Activity Log ---
785
+
786
+ addActivityLog(action: string, title: string, entityType?: string, entityId?: number, details?: Record<string, any>): void {
787
+ this.db.prepare(
788
+ "INSERT INTO activity_log (action, entity_type, entity_id, title, details) VALUES (?, ?, ?, ?, ?)"
789
+ ).run(action, entityType ?? null, entityId ?? null, title, details ? JSON.stringify(details) : null);
790
+ }
791
+
792
+ getActivityLog(limit: number = 50, offset: number = 0, action?: string): ActivityLogEntry[] {
793
+ if (action) {
794
+ return this.db.prepare(
795
+ "SELECT * FROM activity_log WHERE action = ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
796
+ ).all(action, limit, offset) as ActivityLogEntry[];
797
+ }
798
+ return this.db.prepare(
799
+ "SELECT * FROM activity_log ORDER BY created_at DESC LIMIT ? OFFSET ?"
800
+ ).all(limit, offset) as ActivityLogEntry[];
801
+ }
802
+
803
+ getActivityStats(): { total: number; byAction: Record<string, number>; recentDays: { date: string; count: number }[] } {
804
+ const total = (this.db.prepare("SELECT COUNT(*) as c FROM activity_log").get() as any).c;
805
+ const byActionRows = this.db.prepare(
806
+ "SELECT action, COUNT(*) as c FROM activity_log GROUP BY action"
807
+ ).all() as Array<{ action: string; c: number }>;
808
+ const byAction: Record<string, number> = {};
809
+ for (const row of byActionRows) byAction[row.action] = row.c;
810
+ const recentDays = this.db.prepare(
811
+ "SELECT date(created_at) as date, COUNT(*) as count FROM activity_log WHERE created_at >= datetime('now', '-30 days') GROUP BY date(created_at) ORDER BY date DESC"
812
+ ).all() as Array<{ date: string; count: number }>;
813
+ return { total, byAction, recentDays };
814
+ }
815
+
816
+ // --- Content Index ---
817
+
818
+ getPagesBySource(): Array<{
819
+ sourceId: number;
820
+ sourceTitle: string;
821
+ pages: Array<{ id: number; title: string; slug: string; page_type: 'source' | 'concept'; linkCount: number }>;
822
+ }> {
823
+ const rows = this.db.prepare(`
824
+ SELECT p.id, p.title, p.slug, p.page_type, p.source_id,
825
+ COALESCE(s.title, '미분류') as source_title,
826
+ COALESCE(lc.cnt, 0) as link_count
827
+ FROM pages p
828
+ LEFT JOIN sources s ON s.id = p.source_id
829
+ LEFT JOIN (
830
+ SELECT page_id, COUNT(*) as cnt FROM (
831
+ SELECT from_page_id as page_id FROM links
832
+ UNION ALL
833
+ SELECT to_page_id as page_id FROM links
834
+ ) GROUP BY page_id
835
+ ) lc ON lc.page_id = p.id
836
+ ORDER BY COALESCE(s.title, 'zzz'), p.display_order, p.title
837
+ `).all() as Array<{
838
+ id: number; title: string; slug: string; page_type: 'source' | 'concept';
839
+ source_id: number | null; source_title: string; link_count: number;
840
+ }>;
841
+
842
+ const groupMap = new Map<number | -1, {
843
+ sourceId: number;
844
+ sourceTitle: string;
845
+ pages: Array<{ id: number; title: string; slug: string; page_type: 'source' | 'concept'; linkCount: number }>;
846
+ }>();
847
+
848
+ for (const row of rows) {
849
+ const key = row.source_id ?? -1;
850
+ if (!groupMap.has(key)) {
851
+ groupMap.set(key, {
852
+ sourceId: row.source_id ?? -1,
853
+ sourceTitle: row.source_title,
854
+ pages: [],
855
+ });
856
+ }
857
+ groupMap.get(key)!.pages.push({
858
+ id: row.id,
859
+ title: row.title,
860
+ slug: row.slug,
861
+ page_type: row.page_type,
862
+ linkCount: row.link_count,
863
+ });
864
+ }
865
+
866
+ return Array.from(groupMap.values());
867
+ }
868
+
869
+ // --- Citations ---
870
+
871
+ addCitation(pageId: number, sourceId: number, sourcePageId?: number, excerpt?: string, context?: string): number {
872
+ this.db.prepare(
873
+ "INSERT INTO citations (page_id, source_id, source_page_id, excerpt, context) VALUES (?, ?, ?, ?, ?)"
874
+ ).run(pageId, sourceId, sourcePageId ?? null, excerpt ?? null, context ?? null);
875
+ return (this.db.prepare("SELECT last_insert_rowid() as id").get() as any).id;
876
+ }
877
+
878
+ getCitationsForPage(pageId: number): Citation[] {
879
+ return this.db.prepare(`
880
+ SELECT c.*,
881
+ s.title as source_title,
882
+ sp.title as source_page_title,
883
+ sp.slug as source_page_slug
884
+ FROM citations c
885
+ JOIN sources s ON s.id = c.source_id
886
+ LEFT JOIN pages sp ON sp.id = c.source_page_id
887
+ WHERE c.page_id = ?
888
+ ORDER BY c.id
889
+ `).all(pageId) as Citation[];
890
+ }
891
+
892
+ getCitationsForSource(sourceId: number): Citation[] {
893
+ return this.db.prepare(`
894
+ SELECT c.*,
895
+ p.title as page_title,
896
+ p.slug as page_slug,
897
+ sp.title as source_page_title,
898
+ sp.slug as source_page_slug
899
+ FROM citations c
900
+ JOIN pages p ON p.id = c.page_id
901
+ LEFT JOIN pages sp ON sp.id = c.source_page_id
902
+ WHERE c.source_id = ?
903
+ ORDER BY c.id
904
+ `).all(sourceId) as Citation[];
905
+ }
906
+
907
+ getSourceCoverage(): SourceCoverage[] {
908
+ return this.db.prepare(`
909
+ SELECT s.id as sourceId, s.title as sourceTitle,
910
+ COUNT(c.id) as citationCount,
911
+ COUNT(DISTINCT c.page_id) as pageCount
912
+ FROM sources s
913
+ LEFT JOIN citations c ON c.source_id = s.id
914
+ GROUP BY s.id
915
+ ORDER BY citationCount DESC
916
+ `).all() as SourceCoverage[];
917
+ }
918
+
919
+ deleteCitationsForPage(pageId: number): void {
920
+ this.db.prepare("DELETE FROM citations WHERE page_id = ?").run(pageId);
921
+ }
922
+
923
+ getSourceById(id: number): Source | null {
924
+ return (this.db.prepare("SELECT * FROM sources WHERE id = ?").get(id) as Source) ?? null;
925
+ }
926
+
927
+ getPageById(id: number): Page | null {
928
+ return (this.db.prepare("SELECT * FROM pages WHERE id = ?").get(id) as Page) ?? null;
929
+ }
930
+
931
+ /** Update origin metadata for a page (used by promote). */
932
+ updatePageOrigin(slug: string, origin: string, userQuestion: string, parentPageId: number): void {
933
+ this.db
934
+ .prepare("UPDATE pages SET origin = ?, user_question = ?, parent_page_id = ? WHERE slug = ?")
935
+ .run(origin, userQuestion, parentPageId, slug);
936
+ }
937
+
938
+ /** Return lightweight page summaries (no content) for wiki-linking. */
939
+ listPageSummaries(): Array<{ id: number; slug: string; title: string }> {
940
+ return this.db.prepare("SELECT id, slug, title FROM pages ORDER BY title").all() as Array<{
941
+ id: number;
942
+ slug: string;
943
+ title: string;
944
+ }>;
945
+ }
309
946
  }