@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/README.md +189 -62
- package/package.json +1 -1
- package/src/build/renderer.ts +273 -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 +757 -49
- package/src/config.ts +41 -3
- package/src/demo/sample-data.ts +75 -8
- package/src/demo/setup.ts +26 -7
- package/src/expand/llm.ts +2 -2
- package/src/index.ts +497 -64
- 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 +281 -128
- package/src/pipeline/standardizer.ts +41 -0
- package/src/server.ts +466 -33
- 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 +84 -26
- 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 +652 -15
- 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,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:
|
|
302
|
+
pageType: 'source' | 'concept' = "concept",
|
|
156
303
|
displayOrder: number = 0
|
|
157
304
|
): Page {
|
|
158
305
|
this.db
|
|
159
306
|
.prepare(
|
|
160
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|