@open330/kiwimu 0.8.0 → 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,6 +39,22 @@ 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;
@@ -41,11 +62,31 @@ export interface Quiz {
41
62
  answer: string;
42
63
  explanation: string;
43
64
  quiz_type: string; // 'fill_blank' | 'ox' | 'short_answer'
65
+ ease_factor: number;
66
+ interval: number;
67
+ next_review_at: string | null;
44
68
  created_at: string;
45
69
  page_title?: string;
46
70
  page_slug?: string;
47
71
  }
48
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
+
49
90
  const SCHEMA = `
50
91
  CREATE TABLE IF NOT EXISTS sources (
51
92
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -60,10 +101,14 @@ CREATE TABLE IF NOT EXISTS pages (
60
101
  slug TEXT UNIQUE NOT NULL,
61
102
  title TEXT NOT NULL,
62
103
  content TEXT NOT NULL,
63
- source_id INTEGER REFERENCES sources(id),
104
+ source_id INTEGER REFERENCES sources(id) ON DELETE CASCADE,
64
105
  section_anchor TEXT,
65
106
  page_type TEXT NOT NULL DEFAULT 'concept',
66
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,
67
112
  created_at TEXT DEFAULT (datetime('now')),
68
113
  updated_at TEXT DEFAULT (datetime('now'))
69
114
  );
@@ -78,8 +123,8 @@ CREATE TABLE IF NOT EXISTS usage_logs (
78
123
  created_at TEXT DEFAULT (datetime('now'))
79
124
  );
80
125
  CREATE TABLE IF NOT EXISTS links (
81
- from_page_id INTEGER REFERENCES pages(id),
82
- 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,
83
128
  anchor_text TEXT,
84
129
  PRIMARY KEY (from_page_id, to_page_id, anchor_text)
85
130
  );
@@ -90,15 +135,18 @@ CREATE TABLE IF NOT EXISTS quizzes (
90
135
  answer TEXT NOT NULL,
91
136
  explanation TEXT DEFAULT '',
92
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,
93
141
  created_at TEXT DEFAULT (datetime('now')),
94
- FOREIGN KEY (page_id) REFERENCES pages(id)
142
+ FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE
95
143
  );
96
144
  CREATE TABLE IF NOT EXISTS quiz_attempts (
97
145
  id INTEGER PRIMARY KEY AUTOINCREMENT,
98
146
  quiz_id INTEGER NOT NULL,
99
147
  is_correct INTEGER NOT NULL DEFAULT 0,
100
148
  attempted_at TEXT DEFAULT (datetime('now')),
101
- FOREIGN KEY (quiz_id) REFERENCES quizzes(id)
149
+ FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE
102
150
  );
103
151
  CREATE INDEX IF NOT EXISTS idx_pages_source_id ON pages(source_id);
104
152
  CREATE INDEX IF NOT EXISTS idx_attempts_quiz_id ON quiz_attempts(quiz_id);
@@ -106,6 +154,64 @@ CREATE INDEX IF NOT EXISTS idx_pages_page_type ON pages(page_type);
106
154
  CREATE INDEX IF NOT EXISTS idx_links_to_page ON links(to_page_id);
107
155
  CREATE INDEX IF NOT EXISTS idx_links_from_page ON links(from_page_id);
108
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;
109
215
  `;
110
216
 
111
217
  export class Store {
@@ -113,8 +219,10 @@ export class Store {
113
219
 
114
220
  constructor(dbPath: string) {
115
221
  this.db = new Database(dbPath);
222
+ this.db.exec("PRAGMA busy_timeout = 30000");
116
223
  this.db.exec("PRAGMA journal_mode=WAL");
117
224
  this.db.exec("PRAGMA foreign_keys=ON");
225
+ this.initSchema();
118
226
  }
119
227
 
120
228
  initSchema(): void {
@@ -125,6 +233,25 @@ export class Store {
125
233
  } catch {
126
234
  // Column already exists — ignore
127
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
+ }
128
255
  }
129
256
 
130
257
  close(): void {
@@ -152,6 +279,10 @@ export class Store {
152
279
  return (this.db.prepare("SELECT * FROM sources WHERE uri = ?").get(uri) as Source) ?? null;
153
280
  }
154
281
 
282
+ countSources(): number {
283
+ return (this.db.prepare("SELECT COUNT(*) as count FROM sources").get() as any).count;
284
+ }
285
+
155
286
  listSources(): Source[] {
156
287
  return this.db.prepare("SELECT * FROM sources ORDER BY fetched_at DESC").all() as Source[];
157
288
  }
@@ -168,12 +299,21 @@ export class Store {
168
299
  content: string,
169
300
  sourceId?: number,
170
301
  sectionAnchor?: string,
171
- pageType: string = "concept",
302
+ pageType: 'source' | 'concept' = "concept",
172
303
  displayOrder: number = 0
173
304
  ): Page {
174
305
  this.db
175
306
  .prepare(
176
- "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')`
177
317
  )
178
318
  .run(slug, title, content, sourceId ?? null, sectionAnchor ?? null, pageType, displayOrder);
179
319
  return this.db.prepare("SELECT * FROM pages WHERE slug = ?").get(slug) as Page;
@@ -183,6 +323,10 @@ export class Store {
183
323
  return (this.db.prepare("SELECT * FROM pages WHERE slug = ?").get(slug) as Page) ?? null;
184
324
  }
185
325
 
326
+ countPages(): number {
327
+ return (this.db.prepare("SELECT COUNT(*) as count FROM pages").get() as any).count;
328
+ }
329
+
186
330
  listPages(): Page[] {
187
331
  return this.db.prepare("SELECT * FROM pages ORDER BY title").all() as Page[];
188
332
  }
@@ -212,6 +356,10 @@ export class Store {
212
356
  this.db.prepare(
213
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 = ?)"
214
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);
215
363
  this.db.prepare("DELETE FROM pages WHERE source_id = ?").run(sourceId);
216
364
  }
217
365
 
@@ -219,6 +367,7 @@ export class Store {
219
367
  this.db.exec("DELETE FROM quiz_attempts");
220
368
  this.db.exec("DELETE FROM quizzes");
221
369
  this.db.exec("DELETE FROM links");
370
+ this.db.exec("DELETE FROM citations");
222
371
  this.db.exec("DELETE FROM pages");
223
372
  }
224
373
 
@@ -231,6 +380,39 @@ export class Store {
231
380
  this.db.prepare("UPDATE pages SET content = ?, updated_at = datetime('now') WHERE id = ?").run(content, pageId);
232
381
  }
233
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
+
234
416
  // --- Links ---
235
417
 
236
418
  addLink(fromId: number, toId: number, anchorText: string): void {
@@ -251,19 +433,31 @@ export class Store {
251
433
  .all(pageId) as Page[];
252
434
  }
253
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
+
254
444
  getAllLinks(): Link[] {
255
445
  return this.db.prepare("SELECT * FROM links").all() as Link[];
256
446
  }
257
447
 
258
- 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'}>> {
259
453
  const rows = this.db.prepare(`
260
454
  SELECT l.to_page_id, p.id, p.slug, p.title, p.page_type
261
455
  FROM links l
262
456
  JOIN pages p ON p.id = l.from_page_id
263
457
  ORDER BY l.to_page_id
264
- `).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'}>;
265
459
 
266
- 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'}>>();
267
461
  for (const row of rows) {
268
462
  if (!map.has(row.to_page_id)) map.set(row.to_page_id, []);
269
463
  map.get(row.to_page_id)!.push({ id: row.id, slug: row.slug, title: row.title, page_type: row.page_type });
@@ -314,27 +508,72 @@ export class Store {
314
508
  }
315
509
 
316
510
  getSmartQuizzes(count: number): Quiz[] {
511
+ const now = new Date().toISOString();
317
512
  return this.db.prepare(`
318
- SELECT q.*, p.title as page_title, p.slug as page_slug,
319
- COALESCE(a.last_attempt, '1970-01-01') as last_attempt,
320
- COALESCE(a.correct_count, 0) as correct_count,
321
- COALESCE(a.wrong_count, 0) as wrong_count
513
+ SELECT q.*, p.title as page_title, p.slug as page_slug
322
514
  FROM quizzes q
323
515
  JOIN pages p ON p.id = q.page_id
324
- LEFT JOIN (
325
- SELECT quiz_id,
326
- MAX(attempted_at) as last_attempt,
327
- SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END) as correct_count,
328
- SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as wrong_count
329
- FROM quiz_attempts
330
- GROUP BY quiz_id
331
- ) a ON a.quiz_id = q.id
516
+ WHERE q.next_review_at IS NULL OR q.next_review_at <= ?
332
517
  ORDER BY
333
- CASE WHEN a.last_attempt IS NULL THEN 0 ELSE 1 END,
334
- CASE WHEN a.wrong_count > 0 THEN 0 ELSE 1 END,
335
- a.last_attempt ASC
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
336
575
  LIMIT ?
337
- `).all(count) as Quiz[];
576
+ `).all(limit) as any[];
338
577
  }
339
578
 
340
579
  // --- Quiz Attempts ---
@@ -343,6 +582,10 @@ export class Store {
343
582
  this.db
344
583
  .prepare("INSERT INTO quiz_attempts (quiz_id, is_correct) VALUES (?, ?)")
345
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
+ }
346
589
  }
347
590
 
348
591
  getQuizStats(): { total: number; correct: number; incorrect: number; unattempted: number } {
@@ -398,7 +641,7 @@ export class Store {
398
641
 
399
642
  // --- Usage ---
400
643
 
401
- 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 {
402
645
  this.db
403
646
  .prepare("INSERT INTO usage_logs (source_id, llm_calls, prompt_tokens, completion_tokens, total_tokens, estimated_cost_usd) VALUES (?, ?, ?, ?, ?, ?)")
404
647
  .run(sourceId, calls, prompt, completion, total, cost);
@@ -410,4 +653,294 @@ export class Store {
410
653
  ).get() as { totalCalls: number; promptTokens: number; completionTokens: number; totalTokens: number; totalCost: number };
411
654
  return row;
412
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
+ }
413
946
  }