@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/README.md +105 -27
- package/package.json +1 -1
- package/src/build/renderer.ts +272 -32
- package/src/build/static/dynamic-qa.js +423 -0
- package/src/build/static/edit-page.js +58 -0
- package/src/build/static/peek-panel.css +201 -0
- package/src/build/static/peek-panel.js +470 -0
- package/src/build/static/search.js +30 -15
- package/src/build/static/style.css +821 -6
- package/src/build/templates.ts +700 -48
- package/src/config.ts +41 -3
- package/src/demo/sample-data.ts +69 -2
- package/src/demo/setup.ts +25 -6
- package/src/expand/llm.ts +2 -2
- package/src/index.ts +467 -60
- package/src/ingest/docx.ts +1 -1
- package/src/ingest/markdown.ts +21 -0
- package/src/ingest/pdf.ts +4 -2
- package/src/llm-client.ts +63 -69
- package/src/pipeline/citations.ts +107 -0
- package/src/pipeline/llm-chunker.ts +277 -131
- package/src/pipeline/standardizer.ts +41 -0
- package/src/server.ts +465 -32
- package/src/services/dynamic-qa.ts +190 -0
- package/src/services/embedding.ts +122 -0
- package/src/services/index-generator.ts +185 -0
- package/src/services/ingest.ts +83 -25
- package/src/services/lint.ts +249 -0
- package/src/services/promote.ts +150 -0
- package/src/store.test.ts +11 -0
- package/src/store.ts +561 -28
- package/src/utils.ts +30 -0
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:
|
|
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:
|
|
302
|
+
pageType: 'source' | 'concept' = "concept",
|
|
172
303
|
displayOrder: number = 0
|
|
173
304
|
): Page {
|
|
174
305
|
this.db
|
|
175
306
|
.prepare(
|
|
176
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
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(
|
|
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
|
}
|