studylens 0.1.0 → 0.1.2

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/server/index.js CHANGED
@@ -1,555 +1,555 @@
1
- const express = require('express');
2
- const cors = require('cors');
3
- const multer = require('multer');
4
- const path = require('path');
5
- const fs = require('fs');
6
- const storage = require('../core/wiki-storage');
7
- const llm = require('../core/llm-provider');
8
- const extractor = require('../core/extractor');
9
-
10
- const app = express();
11
- app.use(cors());
12
- app.use(express.json({ limit: '10mb' }));
13
-
14
- const uploadDir = path.join(__dirname, '..', 'uploads');
15
- if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
16
- const upload = multer({ dest: uploadDir, limits: { fileSize: 20 * 1024 * 1024 } });
17
-
18
- async function processKnowledgePoints(knowledgePoints, subject, sourceType, sourceRef) {
19
- const existingEntries = storage.getAllEntries();
20
- let duplicates = [];
21
- try {
22
- duplicates = await llm.checkDuplicates(knowledgePoints, existingEntries);
23
- } catch (_) {}
24
- const dupNewIndices = new Set(duplicates.map(d => d.newIndex));
25
- const created = [];
26
- const skipped = [];
27
- for (let i = 0; i < knowledgePoints.length; i++) {
28
- if (dupNewIndices.has(i)) {
29
- const dup = duplicates.find(d => d.newIndex === i);
30
- skipped.push({ ...knowledgePoints[i], duplicateOf: dup.existingId, duplicateTitle: dup.existingTitle, reason: dup.reason });
31
- continue;
32
- }
33
- const kp = knowledgePoints[i];
34
- const entry = storage.addEntry({
35
- title: kp.title, content: kp.content,
36
- subject: kp.subject || subject || '', tags: kp.tags || [],
37
- source_type: sourceType, source_ref: sourceRef,
38
- });
39
- created.push(entry);
40
- try {
41
- const connections = await llm.findConnections(entry, existingEntries);
42
- for (const conn of connections) {
43
- if (existingEntries.some(e => e.id === conn.id)) storage.addConnection(entry.id, conn.id, conn.relation);
44
- }
45
- } catch (_) {}
46
- existingEntries.push(entry);
47
- }
48
- return { created, skipped };
49
- }
50
-
51
- if (process.env.STUDYLENS_TEST_MODE) {
52
- app.post('/api/test/seed', (req, res) => {
53
- try {
54
- const entry = storage.addEntry(req.body);
55
- res.json(entry);
56
- } catch (err) { res.status(500).json({ error: err.message }); }
57
- });
58
- }
59
-
60
- // Get all entries + connections for graph
61
- app.get('/api/graph', (req, res) => {
62
- const allEntries = storage.getAllEntries();
63
- const statusMap = storage.getTopicPageStatusMap();
64
- const entries = allEntries.filter(e => !e.parent_id).map(e => ({
65
- ...e,
66
- has_children: allEntries.some(c => c.parent_id === e.id),
67
- has_topic_page: !!(statusMap[e.id]?.has_topic_page),
68
- has_qa: !!(statusMap[e.id]?.has_qa),
69
- }));
70
- const connections = storage.getAllConnections();
71
- res.json({ entries, connections });
72
- });
73
-
74
- // Get all entries
75
- app.get('/api/entries', (req, res) => {
76
- const { q } = req.query;
77
- const entries = q ? storage.searchEntries(q) : storage.getAllEntries();
78
- res.json(entries);
79
- });
80
-
81
- // Get single entry
82
- app.get('/api/entries/:id', (req, res) => {
83
- const entry = storage.getEntry(req.params.id);
84
- if (!entry) return res.status(404).json({ error: 'Not found' });
85
- res.json(entry);
86
- });
87
-
88
- // Ingest text → LLM analyze → store entries + auto-connect
89
- app.post('/api/ingest', async (req, res) => {
90
- try {
91
- const { text, subject, source_type = 'text', source_ref = '', maxPoints } = req.body;
92
- if (!text) return res.status(400).json({ error: 'text is required' });
93
-
94
- storage.addRaw(text, source_type, source_ref);
95
- const knowledgePoints = await llm.analyze(text, subject, maxPoints);
96
- const { created, skipped } = await processKnowledgePoints(knowledgePoints, subject, source_type, source_ref);
97
-
98
- res.json({ created, skipped });
99
- } catch (err) {
100
- res.status(500).json({ error: err.message });
101
- }
102
- });
103
-
104
- // Add manual connection
105
- app.post('/api/connections', (req, res) => {
106
- const { from_id, to_id, relation = '' } = req.body;
107
- if (!from_id || !to_id) return res.status(400).json({ error: 'from_id and to_id required' });
108
- const conn = storage.addConnection(from_id, to_id, relation);
109
- res.json(conn);
110
- });
111
-
112
- // Q&A: ask a question with existing knowledge as context
113
- app.post('/api/qa', async (req, res) => {
114
- try {
115
- const { question, history } = req.body;
116
- if (!question) return res.status(400).json({ error: 'question is required' });
117
- const allEntries = storage.getAllEntries();
118
- const searchText = [question, ...(history || []).map(h => h.question)].join(' ');
119
- const keywords = [];
120
- for (let len = 4; len >= 2; len--) {
121
- for (let i = 0; i <= searchText.length - len; i++) {
122
- const w = searchText.slice(i, i + len);
123
- if (/^[一-鿿]+$/.test(w)) keywords.push(w);
124
- }
125
- }
126
- const uniqueKw = [...new Set(keywords)];
127
- const relevant = allEntries.filter(e => {
128
- const text = e.title + e.content + (e.tags || []).join(' ') + e.subject;
129
- return uniqueKw.some(kw => text.includes(kw));
130
- }).slice(0, 20);
131
- const result = await llm.askQuestion(question, relevant.length > 0 ? relevant : allEntries.slice(0, 10), history || []);
132
- res.json({ ...result, relatedEntries: relevant.map(e => ({ id: e.id, title: e.title, subject: e.subject })) });
133
- } catch (err) {
134
- res.status(500).json({ error: err.message });
135
- }
136
- });
137
-
138
- // Q&A: save suggested cards
139
- app.post('/api/qa/save', async (req, res) => {
140
- try {
141
- const { question, cards } = req.body;
142
- if (!cards?.length) return res.status(400).json({ error: 'cards required' });
143
- const existingEntries = storage.getAllEntries();
144
- const created = [];
145
- for (const card of cards) {
146
- const entry = storage.addEntry({
147
- title: card.title,
148
- content: card.content,
149
- subject: card.subject || '',
150
- tags: card.tags || [],
151
- source_type: 'qa',
152
- source_ref: question || '',
153
- });
154
- created.push(entry);
155
- try {
156
- const connections = await llm.findConnections(entry, existingEntries);
157
- for (const conn of connections) {
158
- if (existingEntries.some(e => e.id === conn.id)) {
159
- storage.addConnection(entry.id, conn.id, conn.relation);
160
- }
161
- }
162
- } catch (_) {}
163
- existingEntries.push(entry);
164
- }
165
- res.json({ created });
166
- } catch (err) {
167
- res.status(500).json({ error: err.message });
168
- }
169
- });
170
-
171
- // Ingest from file upload (PDF, Word, Excel, txt)
172
- app.post('/api/ingest/file', upload.single('file'), async (req, res) => {
173
- try {
174
- if (!req.file) return res.status(400).json({ error: 'file is required' });
175
- const origName = req.file.originalname;
176
- const ext = path.extname(origName).toLowerCase();
177
- const dest = req.file.path + ext;
178
- fs.renameSync(req.file.path, dest);
179
-
180
- const text = await extractor.extractFromFile(dest);
181
- fs.unlinkSync(dest);
182
-
183
- storage.addRaw(text, ext.replace('.', ''), origName);
184
- const subject = req.body.subject || '';
185
- const maxPoints = req.body.maxPoints ? parseInt(req.body.maxPoints, 10) : undefined;
186
- const knowledgePoints = await llm.analyze(text.slice(0, 10000), subject, maxPoints);
187
- const { created, skipped } = await processKnowledgePoints(knowledgePoints, subject, ext.replace('.', ''), origName);
188
- res.json({ created, skipped, extractedLength: text.length });
189
- } catch (err) {
190
- if (req.file) try { fs.unlinkSync(req.file.path); } catch (_) {}
191
- res.status(500).json({ error: err.message });
192
- }
193
- });
194
-
195
- // Ingest from URL
196
- app.post('/api/ingest/url', async (req, res) => {
197
- try {
198
- const { url, subject, maxPoints } = req.body;
199
- if (!url) return res.status(400).json({ error: 'url is required' });
200
-
201
- const text = await extractor.extractFromUrl(url);
202
- storage.addRaw(text, 'url', url);
203
- const knowledgePoints = await llm.analyze(text.slice(0, 10000), subject || '', maxPoints);
204
- const { created, skipped } = await processKnowledgePoints(knowledgePoints, subject || '', 'url', url);
205
- res.json({ created, skipped, extractedLength: text.length });
206
- } catch (err) {
207
- res.status(500).json({ error: err.message });
208
- }
209
- });
210
-
211
- // Update entry
212
- app.put('/api/entries/:id', (req, res) => {
213
- const { title, content, subject, tags, sort_order } = req.body;
214
- const entry = storage.updateEntry(req.params.id, { title, content, subject, tags, sort_order });
215
- if (!entry) return res.status(404).json({ error: 'Not found' });
216
- res.json(entry);
217
- });
218
-
219
- // Delete entry
220
- app.delete('/api/entries/:id', (req, res) => {
221
- storage.deleteEntry(req.params.id);
222
- res.json({ ok: true });
223
- });
224
-
225
- // Restructure: user instructs how to reorganize knowledge graph
226
- app.post('/api/restructure', async (req, res) => {
227
- try {
228
- const { instruction, subject } = req.body;
229
- if (!instruction) return res.status(400).json({ error: 'instruction is required' });
230
- let entries = storage.getAllEntries();
231
- if (subject) entries = entries.filter(e => e.subject === subject || e.subject?.startsWith(subject));
232
- const changes = await llm.restructure(instruction, entries);
233
- const applied = [];
234
- for (const change of changes) {
235
- if (change.action === 'update' && change.id) {
236
- const existing = storage.getEntry(change.id);
237
- if (existing) {
238
- const updated = storage.updateEntry(change.id, {
239
- title: change.title || existing.title,
240
- content: existing.content,
241
- subject: change.subject || existing.subject,
242
- tags: change.tags || existing.tags,
243
- });
244
- if (updated) applied.push({ action: 'update', id: change.id, title: updated.title, subject: updated.subject });
245
- }
246
- } else if (change.action === 'merge' && change.ids?.length > 1) {
247
- const mergedEntry = storage.addEntry({
248
- title: change.merged_title,
249
- content: change.merged_content,
250
- subject: change.subject || '',
251
- tags: change.tags || [],
252
- source_type: 'merge',
253
- source_ref: change.ids.join(','),
254
- });
255
- for (const id of change.ids) storage.deleteEntry(id);
256
- applied.push({ action: 'merge', ids: change.ids, newId: mergedEntry.id, title: mergedEntry.title });
257
- }
258
- }
259
- res.json({ changes: applied, total: applied.length });
260
- } catch (err) {
261
- res.status(500).json({ error: err.message });
262
- }
263
- });
264
-
265
- // QA mind map: generate mind map structure from QA result
266
- app.post('/api/qa/mindmap', async (req, res) => {
267
- try {
268
- const { question, answer, cards, relatedEntries } = req.body;
269
- console.log('[mindmap] received question:', question?.slice(0, 50), 'answer:', answer?.slice(0, 50));
270
- if (!question) return res.status(400).json({ error: 'question is required' });
271
- const mindmap = await llm.buildQAMindMap(question, answer || '', cards || [], relatedEntries || []);
272
- console.log('[mindmap] type:', mindmap?.type, 'title:', mindmap?.title);
273
- if (!mindmap || !mindmap.type || !['comparison', 'timeline', 'tree'].includes(mindmap.type)) {
274
- console.warn('[mindmap] Invalid response, returning fallback tree');
275
- return res.json({ type: 'tree', title: question.slice(0, 20), branches: [] });
276
- }
277
- res.json(mindmap);
278
- } catch (err) {
279
- console.error('[mindmap] Error:', err.message);
280
- res.status(500).json({ error: err.message });
281
- }
282
- });
283
-
284
- // Generate smart questions for an entry
285
- app.post('/api/entries/:id/questions', async (req, res) => {
286
- try {
287
- const entry = storage.getEntry(req.params.id);
288
- if (!entry) return res.status(404).json({ error: 'entry not found' });
289
- const existingQa = storage.getEntryQA(req.params.id);
290
- const questions = await llm.generateSmartQuestions(entry, existingQa);
291
- res.json({ questions });
292
- } catch (err) {
293
- res.status(500).json({ error: err.message });
294
- }
295
- });
296
-
297
- // Ask a question scoped to a specific entry
298
- app.post('/api/entries/:id/ask', async (req, res) => {
299
- try {
300
- const entry = storage.getEntry(req.params.id);
301
- if (!entry) return res.status(404).json({ error: 'entry not found' });
302
- const { question, history } = req.body;
303
- if (!question) return res.status(400).json({ error: 'question is required' });
304
- const allEntries = storage.getAllEntries();
305
- const related = allEntries.filter(e => {
306
- if (e.id === entry.id) return false;
307
- const eTags = new Set(e.tags || []);
308
- return (entry.tags || []).some(t => eTags.has(t)) || e.subject === entry.subject;
309
- }).slice(0, 15);
310
- const context = [entry, ...related];
311
- const result = await llm.askQuestion(question, context, history || []);
312
- // Auto-save QA to independent storage
313
- if (result.answer) {
314
- const existing = storage.getEntryQA(req.params.id);
315
- existing.push({ question, answer: result.answer, category: result.category || '' });
316
- storage.saveEntryQA(req.params.id, existing);
317
- }
318
- res.json(result);
319
- } catch (err) {
320
- res.status(500).json({ error: err.message });
321
- }
322
- });
323
-
324
- // Independent QA history for an entry
325
- app.get('/api/entries/:id/qa', (req, res) => {
326
- res.json(storage.getEntryQA(req.params.id));
327
- });
328
-
329
- app.put('/api/entries/:id/qa', (req, res) => {
330
- storage.saveEntryQA(req.params.id, req.body.qaHistory || []);
331
- res.json({ ok: true });
332
- });
333
-
334
- // Generate HTML topic page for an entry
335
- app.post('/api/entries/:id/topic-page', async (req, res) => {
336
- try {
337
- const entry = storage.getEntry(req.params.id);
338
- if (!entry) return res.status(404).json({ error: 'entry not found' });
339
- const allEntries = storage.getAllEntries();
340
- const related = allEntries.filter(e => {
341
- if (e.id === entry.id) return false;
342
- const eTags = new Set(e.tags || []);
343
- return (entry.tags || []).some(t => eTags.has(t)) || e.subject === entry.subject;
344
- }).slice(0, 10);
345
- const html = await llm.generateTopicHTML(entry, related, req.body.qaHistory || [], req.body.existingHTML || '', req.body.requirements || '', req.body.mode || '');
346
- res.json({ html });
347
- } catch (err) {
348
- console.error('[topic-page] Error:', err.message, '\n', err.stack);
349
- res.status(500).json({ error: err.message });
350
- }
351
- });
352
-
353
- const PORT = process.env.PORT || 3000;
354
-
355
- // Save topic page (creates new version)
356
- app.post('/api/entries/:id/topic-page/save', (req, res) => {
357
- try {
358
- const { html, qaHistory, comments, includedQaIds } = req.body;
359
- if (!html) return res.status(400).json({ error: 'html is required' });
360
- if (html.replace(/<[^>]*>/g, '').trim().length < 50) return res.status(400).json({ error: 'content too short' });
361
- const page = storage.saveTopicPage(req.params.id, html, qaHistory || [], comments || [], includedQaIds || []);
362
- res.json(page);
363
- } catch (err) {
364
- res.status(500).json({ error: err.message });
365
- }
366
- });
367
-
368
- // Get all topic page versions for an entry
369
- app.get('/api/entries/:id/topic-pages', (req, res) => {
370
- const pages = storage.getTopicPages(req.params.id);
371
- res.json({ pages });
372
- });
373
-
374
- // Get latest topic page for an entry
375
- app.get('/api/entries/:id/topic-page/latest', (req, res) => {
376
- const page = storage.getLatestTopicPage(req.params.id);
377
- res.json({ page });
378
- });
379
-
380
- // Update comments on a topic page
381
- app.put('/api/topic-pages/:pageId/comments', (req, res) => {
382
- try {
383
- const { comments } = req.body;
384
- storage.updateTopicPageComments(req.params.pageId, comments || []);
385
- res.json({ ok: true });
386
- } catch (err) {
387
- res.status(500).json({ error: err.message });
388
- }
389
- });
390
-
391
- app.put('/api/topic-pages/:pageId/qa-history', (req, res) => {
392
- try {
393
- const { qaHistory } = req.body;
394
- storage.updateTopicPageQaHistory(req.params.pageId, qaHistory || []);
395
- res.json({ ok: true });
396
- } catch (err) {
397
- res.status(500).json({ error: err.message });
398
- }
399
- });
400
-
401
- // Delete a specific topic page version
402
- app.delete('/api/entries/:id/topic-page/:version', (req, res) => {
403
- try {
404
- const version = parseInt(req.params.version, 10);
405
- if (!version) return res.status(400).json({ error: 'invalid version' });
406
- const ok = storage.deleteTopicPageVersion(req.params.id, version);
407
- res.json({ ok });
408
- } catch (err) {
409
- res.status(500).json({ error: err.message });
410
- }
411
- });
412
-
413
- // Get a specific topic page version
414
- app.get('/api/entries/:id/topic-page/version/:version', (req, res) => {
415
- try {
416
- const version = parseInt(req.params.version, 10);
417
- const page = storage.getTopicPageByVersion(req.params.id, version);
418
- res.json({ page });
419
- } catch (err) {
420
- res.status(500).json({ error: err.message });
421
- }
422
- });
423
-
424
- // Get children of an entry (for deep analysis)
425
- app.get('/api/entries/:id/children', (req, res) => {
426
- try {
427
- const children = storage.getChildren(req.params.id);
428
- res.json({ children });
429
- } catch (err) {
430
- res.status(500).json({ error: err.message });
431
- }
432
- });
433
-
434
- // Expand an entry into sub-nodes using AI
435
- app.post('/api/entries/:id/expand', async (req, res) => {
436
- try {
437
- const entry = storage.getEntry(req.params.id);
438
- if (!entry) return res.status(404).json({ error: 'entry not found' });
439
- const topicPage = storage.getLatestTopicPage(req.params.id);
440
- const qaHistory = (topicPage && topicPage.qa_history) || [];
441
- const subTopics = await llm.expandEntry(entry, qaHistory);
442
- console.log(`[expand] ${entry.title}: got ${subTopics.length} sub-topics`);
443
- const children = [];
444
- for (const sub of subTopics) {
445
- const child = storage.addEntry({
446
- title: sub.title,
447
- content: sub.content,
448
- subject: entry.subject,
449
- tags: [sub.category, entry.title].filter(Boolean),
450
- source_type: 'deep-analysis',
451
- source_ref: entry.id,
452
- parent_id: entry.id,
453
- });
454
- children.push(child);
455
- }
456
- res.json({ children });
457
- } catch (err) {
458
- res.status(500).json({ error: err.message });
459
- }
460
- });
461
-
462
- // Add a single child entry manually
463
- app.post('/api/entries/:id/children', (req, res) => {
464
- try {
465
- const { title, content, tags = [] } = req.body;
466
- const parent = storage.getEntry(req.params.id);
467
- if (!parent) return res.status(404).json({ error: 'parent not found' });
468
- const child = storage.addEntry({
469
- title, content,
470
- subject: parent.subject,
471
- tags,
472
- source_type: 'deep-analysis',
473
- source_ref: req.params.id,
474
- parent_id: req.params.id,
475
- });
476
- res.json(child);
477
- } catch (err) {
478
- res.status(500).json({ error: err.message });
479
- }
480
- });
481
-
482
- // Settings (prompts — tracked in git)
483
- const settingsPath = path.join(__dirname, '..', 'config', 'prompts.json');
484
-
485
- function loadSettings() {
486
- try { return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); }
487
- catch { return { subjects: {}, defaultPrompts: {} }; }
488
- }
489
- function saveSettings(data) {
490
- const dir = path.dirname(settingsPath);
491
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
492
- fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2));
493
- }
494
-
495
- app.get('/api/settings', (req, res) => {
496
- res.json(loadSettings());
497
- });
498
-
499
- app.put('/api/settings', (req, res) => {
500
- saveSettings(req.body);
501
- res.json({ ok: true });
502
- });
503
-
504
- app.get('/api/llm/config', (req, res) => {
505
- try {
506
- res.json(llm.loadLLMConfig());
507
- } catch (err) {
508
- res.status(500).json({ error: err.message });
509
- }
510
- });
511
-
512
- app.post('/api/llm/config', (req, res) => {
513
- try {
514
- llm.saveLLMConfig(req.body);
515
- res.json({ ok: true });
516
- } catch (err) {
517
- res.status(500).json({ error: err.message });
518
- }
519
- });
520
-
521
- app.post('/api/llm/test', async (req, res) => {
522
- try {
523
- const { providerName } = req.body;
524
- const config = llm.loadLLMConfig();
525
- const providerCfg = config.providers[providerName];
526
- if (!providerCfg) return res.status(400).json({ error: `Unknown provider: ${providerName}` });
527
-
528
- if (providerName === 'agent-maestro') {
529
- const ok = await llm.probeAgentMaestro(config);
530
- return res.json({ ok, message: ok ? 'Agent Maestro is reachable' : 'Agent Maestro is not reachable' });
531
- }
532
-
533
- const result = await llm.callLLM(
534
- [{ role: 'user', content: 'Reply with exactly: OK' }],
535
- { maxTokens: 16, providers: [{ name: providerName, ...providerCfg }] }
536
- );
537
- res.json({ ok: true, message: `Response: ${result.slice(0, 100)}` });
538
- } catch (err) {
539
- res.json({ ok: false, message: err.message });
540
- }
541
- });
542
-
543
- const portalDist = path.join(__dirname, '..', 'portal', 'dist');
544
- if (fs.existsSync(portalDist)) {
545
- app.use(express.static(portalDist));
546
- app.get('*', (req, res) => {
547
- res.sendFile(path.join(portalDist, 'index.html'));
548
- });
549
- }
550
-
551
- if (require.main === module) {
552
- app.listen(PORT, () => console.log(`StudyLens server running on http://localhost:${PORT}`));
553
- }
554
-
555
- module.exports = app;
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const multer = require('multer');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const storage = require('../core/wiki-storage');
7
+ const llm = require('../core/llm-provider');
8
+ const extractor = require('../core/extractor');
9
+
10
+ const app = express();
11
+ app.use(cors());
12
+ app.use(express.json({ limit: '10mb' }));
13
+
14
+ const uploadDir = path.join(__dirname, '..', 'uploads');
15
+ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
16
+ const upload = multer({ dest: uploadDir, limits: { fileSize: 20 * 1024 * 1024 } });
17
+
18
+ async function processKnowledgePoints(knowledgePoints, subject, sourceType, sourceRef) {
19
+ const existingEntries = storage.getAllEntries();
20
+ let duplicates = [];
21
+ try {
22
+ duplicates = await llm.checkDuplicates(knowledgePoints, existingEntries);
23
+ } catch (_) {}
24
+ const dupNewIndices = new Set(duplicates.map(d => d.newIndex));
25
+ const created = [];
26
+ const skipped = [];
27
+ for (let i = 0; i < knowledgePoints.length; i++) {
28
+ if (dupNewIndices.has(i)) {
29
+ const dup = duplicates.find(d => d.newIndex === i);
30
+ skipped.push({ ...knowledgePoints[i], duplicateOf: dup.existingId, duplicateTitle: dup.existingTitle, reason: dup.reason });
31
+ continue;
32
+ }
33
+ const kp = knowledgePoints[i];
34
+ const entry = storage.addEntry({
35
+ title: kp.title, content: kp.content,
36
+ subject: kp.subject || subject || '', tags: kp.tags || [],
37
+ source_type: sourceType, source_ref: sourceRef,
38
+ });
39
+ created.push(entry);
40
+ try {
41
+ const connections = await llm.findConnections(entry, existingEntries);
42
+ for (const conn of connections) {
43
+ if (existingEntries.some(e => e.id === conn.id)) storage.addConnection(entry.id, conn.id, conn.relation);
44
+ }
45
+ } catch (_) {}
46
+ existingEntries.push(entry);
47
+ }
48
+ return { created, skipped };
49
+ }
50
+
51
+ if (process.env.STUDYLENS_TEST_MODE) {
52
+ app.post('/api/test/seed', (req, res) => {
53
+ try {
54
+ const entry = storage.addEntry(req.body);
55
+ res.json(entry);
56
+ } catch (err) { res.status(500).json({ error: err.message }); }
57
+ });
58
+ }
59
+
60
+ // Get all entries + connections for graph
61
+ app.get('/api/graph', (req, res) => {
62
+ const allEntries = storage.getAllEntries();
63
+ const statusMap = storage.getTopicPageStatusMap();
64
+ const entries = allEntries.filter(e => !e.parent_id).map(e => ({
65
+ ...e,
66
+ has_children: allEntries.some(c => c.parent_id === e.id),
67
+ has_topic_page: !!(statusMap[e.id]?.has_topic_page),
68
+ has_qa: !!(statusMap[e.id]?.has_qa),
69
+ }));
70
+ const connections = storage.getAllConnections();
71
+ res.json({ entries, connections });
72
+ });
73
+
74
+ // Get all entries
75
+ app.get('/api/entries', (req, res) => {
76
+ const { q } = req.query;
77
+ const entries = q ? storage.searchEntries(q) : storage.getAllEntries();
78
+ res.json(entries);
79
+ });
80
+
81
+ // Get single entry
82
+ app.get('/api/entries/:id', (req, res) => {
83
+ const entry = storage.getEntry(req.params.id);
84
+ if (!entry) return res.status(404).json({ error: 'Not found' });
85
+ res.json(entry);
86
+ });
87
+
88
+ // Ingest text → LLM analyze → store entries + auto-connect
89
+ app.post('/api/ingest', async (req, res) => {
90
+ try {
91
+ const { text, subject, source_type = 'text', source_ref = '', maxPoints } = req.body;
92
+ if (!text) return res.status(400).json({ error: 'text is required' });
93
+
94
+ storage.addRaw(text, source_type, source_ref);
95
+ const knowledgePoints = await llm.analyze(text, subject, maxPoints);
96
+ const { created, skipped } = await processKnowledgePoints(knowledgePoints, subject, source_type, source_ref);
97
+
98
+ res.json({ created, skipped });
99
+ } catch (err) {
100
+ res.status(500).json({ error: err.message });
101
+ }
102
+ });
103
+
104
+ // Add manual connection
105
+ app.post('/api/connections', (req, res) => {
106
+ const { from_id, to_id, relation = '' } = req.body;
107
+ if (!from_id || !to_id) return res.status(400).json({ error: 'from_id and to_id required' });
108
+ const conn = storage.addConnection(from_id, to_id, relation);
109
+ res.json(conn);
110
+ });
111
+
112
+ // Q&A: ask a question with existing knowledge as context
113
+ app.post('/api/qa', async (req, res) => {
114
+ try {
115
+ const { question, history } = req.body;
116
+ if (!question) return res.status(400).json({ error: 'question is required' });
117
+ const allEntries = storage.getAllEntries();
118
+ const searchText = [question, ...(history || []).map(h => h.question)].join(' ');
119
+ const keywords = [];
120
+ for (let len = 4; len >= 2; len--) {
121
+ for (let i = 0; i <= searchText.length - len; i++) {
122
+ const w = searchText.slice(i, i + len);
123
+ if (/^[一-鿿]+$/.test(w)) keywords.push(w);
124
+ }
125
+ }
126
+ const uniqueKw = [...new Set(keywords)];
127
+ const relevant = allEntries.filter(e => {
128
+ const text = e.title + e.content + (e.tags || []).join(' ') + e.subject;
129
+ return uniqueKw.some(kw => text.includes(kw));
130
+ }).slice(0, 20);
131
+ const result = await llm.askQuestion(question, relevant.length > 0 ? relevant : allEntries.slice(0, 10), history || []);
132
+ res.json({ ...result, relatedEntries: relevant.map(e => ({ id: e.id, title: e.title, subject: e.subject })) });
133
+ } catch (err) {
134
+ res.status(500).json({ error: err.message });
135
+ }
136
+ });
137
+
138
+ // Q&A: save suggested cards
139
+ app.post('/api/qa/save', async (req, res) => {
140
+ try {
141
+ const { question, cards } = req.body;
142
+ if (!cards?.length) return res.status(400).json({ error: 'cards required' });
143
+ const existingEntries = storage.getAllEntries();
144
+ const created = [];
145
+ for (const card of cards) {
146
+ const entry = storage.addEntry({
147
+ title: card.title,
148
+ content: card.content,
149
+ subject: card.subject || '',
150
+ tags: card.tags || [],
151
+ source_type: 'qa',
152
+ source_ref: question || '',
153
+ });
154
+ created.push(entry);
155
+ try {
156
+ const connections = await llm.findConnections(entry, existingEntries);
157
+ for (const conn of connections) {
158
+ if (existingEntries.some(e => e.id === conn.id)) {
159
+ storage.addConnection(entry.id, conn.id, conn.relation);
160
+ }
161
+ }
162
+ } catch (_) {}
163
+ existingEntries.push(entry);
164
+ }
165
+ res.json({ created });
166
+ } catch (err) {
167
+ res.status(500).json({ error: err.message });
168
+ }
169
+ });
170
+
171
+ // Ingest from file upload (PDF, Word, Excel, txt)
172
+ app.post('/api/ingest/file', upload.single('file'), async (req, res) => {
173
+ try {
174
+ if (!req.file) return res.status(400).json({ error: 'file is required' });
175
+ const origName = req.file.originalname;
176
+ const ext = path.extname(origName).toLowerCase();
177
+ const dest = req.file.path + ext;
178
+ fs.renameSync(req.file.path, dest);
179
+
180
+ const text = await extractor.extractFromFile(dest);
181
+ fs.unlinkSync(dest);
182
+
183
+ storage.addRaw(text, ext.replace('.', ''), origName);
184
+ const subject = req.body.subject || '';
185
+ const maxPoints = req.body.maxPoints ? parseInt(req.body.maxPoints, 10) : undefined;
186
+ const knowledgePoints = await llm.analyze(text.slice(0, 10000), subject, maxPoints);
187
+ const { created, skipped } = await processKnowledgePoints(knowledgePoints, subject, ext.replace('.', ''), origName);
188
+ res.json({ created, skipped, extractedLength: text.length });
189
+ } catch (err) {
190
+ if (req.file) try { fs.unlinkSync(req.file.path); } catch (_) {}
191
+ res.status(500).json({ error: err.message });
192
+ }
193
+ });
194
+
195
+ // Ingest from URL
196
+ app.post('/api/ingest/url', async (req, res) => {
197
+ try {
198
+ const { url, subject, maxPoints } = req.body;
199
+ if (!url) return res.status(400).json({ error: 'url is required' });
200
+
201
+ const text = await extractor.extractFromUrl(url);
202
+ storage.addRaw(text, 'url', url);
203
+ const knowledgePoints = await llm.analyze(text.slice(0, 10000), subject || '', maxPoints);
204
+ const { created, skipped } = await processKnowledgePoints(knowledgePoints, subject || '', 'url', url);
205
+ res.json({ created, skipped, extractedLength: text.length });
206
+ } catch (err) {
207
+ res.status(500).json({ error: err.message });
208
+ }
209
+ });
210
+
211
+ // Update entry
212
+ app.put('/api/entries/:id', (req, res) => {
213
+ const { title, content, subject, tags, sort_order } = req.body;
214
+ const entry = storage.updateEntry(req.params.id, { title, content, subject, tags, sort_order });
215
+ if (!entry) return res.status(404).json({ error: 'Not found' });
216
+ res.json(entry);
217
+ });
218
+
219
+ // Delete entry
220
+ app.delete('/api/entries/:id', (req, res) => {
221
+ storage.deleteEntry(req.params.id);
222
+ res.json({ ok: true });
223
+ });
224
+
225
+ // Restructure: user instructs how to reorganize knowledge graph
226
+ app.post('/api/restructure', async (req, res) => {
227
+ try {
228
+ const { instruction, subject } = req.body;
229
+ if (!instruction) return res.status(400).json({ error: 'instruction is required' });
230
+ let entries = storage.getAllEntries();
231
+ if (subject) entries = entries.filter(e => e.subject === subject || e.subject?.startsWith(subject));
232
+ const changes = await llm.restructure(instruction, entries);
233
+ const applied = [];
234
+ for (const change of changes) {
235
+ if (change.action === 'update' && change.id) {
236
+ const existing = storage.getEntry(change.id);
237
+ if (existing) {
238
+ const updated = storage.updateEntry(change.id, {
239
+ title: change.title || existing.title,
240
+ content: existing.content,
241
+ subject: change.subject || existing.subject,
242
+ tags: change.tags || existing.tags,
243
+ });
244
+ if (updated) applied.push({ action: 'update', id: change.id, title: updated.title, subject: updated.subject });
245
+ }
246
+ } else if (change.action === 'merge' && change.ids?.length > 1) {
247
+ const mergedEntry = storage.addEntry({
248
+ title: change.merged_title,
249
+ content: change.merged_content,
250
+ subject: change.subject || '',
251
+ tags: change.tags || [],
252
+ source_type: 'merge',
253
+ source_ref: change.ids.join(','),
254
+ });
255
+ for (const id of change.ids) storage.deleteEntry(id);
256
+ applied.push({ action: 'merge', ids: change.ids, newId: mergedEntry.id, title: mergedEntry.title });
257
+ }
258
+ }
259
+ res.json({ changes: applied, total: applied.length });
260
+ } catch (err) {
261
+ res.status(500).json({ error: err.message });
262
+ }
263
+ });
264
+
265
+ // QA mind map: generate mind map structure from QA result
266
+ app.post('/api/qa/mindmap', async (req, res) => {
267
+ try {
268
+ const { question, answer, cards, relatedEntries } = req.body;
269
+ console.log('[mindmap] received question:', question?.slice(0, 50), 'answer:', answer?.slice(0, 50));
270
+ if (!question) return res.status(400).json({ error: 'question is required' });
271
+ const mindmap = await llm.buildQAMindMap(question, answer || '', cards || [], relatedEntries || []);
272
+ console.log('[mindmap] type:', mindmap?.type, 'title:', mindmap?.title);
273
+ if (!mindmap || !mindmap.type || !['comparison', 'timeline', 'tree'].includes(mindmap.type)) {
274
+ console.warn('[mindmap] Invalid response, returning fallback tree');
275
+ return res.json({ type: 'tree', title: question.slice(0, 20), branches: [] });
276
+ }
277
+ res.json(mindmap);
278
+ } catch (err) {
279
+ console.error('[mindmap] Error:', err.message);
280
+ res.status(500).json({ error: err.message });
281
+ }
282
+ });
283
+
284
+ // Generate smart questions for an entry
285
+ app.post('/api/entries/:id/questions', async (req, res) => {
286
+ try {
287
+ const entry = storage.getEntry(req.params.id);
288
+ if (!entry) return res.status(404).json({ error: 'entry not found' });
289
+ const existingQa = storage.getEntryQA(req.params.id);
290
+ const questions = await llm.generateSmartQuestions(entry, existingQa);
291
+ res.json({ questions });
292
+ } catch (err) {
293
+ res.status(500).json({ error: err.message });
294
+ }
295
+ });
296
+
297
+ // Ask a question scoped to a specific entry
298
+ app.post('/api/entries/:id/ask', async (req, res) => {
299
+ try {
300
+ const entry = storage.getEntry(req.params.id);
301
+ if (!entry) return res.status(404).json({ error: 'entry not found' });
302
+ const { question, history } = req.body;
303
+ if (!question) return res.status(400).json({ error: 'question is required' });
304
+ const allEntries = storage.getAllEntries();
305
+ const related = allEntries.filter(e => {
306
+ if (e.id === entry.id) return false;
307
+ const eTags = new Set(e.tags || []);
308
+ return (entry.tags || []).some(t => eTags.has(t)) || e.subject === entry.subject;
309
+ }).slice(0, 15);
310
+ const context = [entry, ...related];
311
+ const result = await llm.askQuestion(question, context, history || []);
312
+ // Auto-save QA to independent storage
313
+ if (result.answer) {
314
+ const existing = storage.getEntryQA(req.params.id);
315
+ existing.push({ question, answer: result.answer, category: result.category || '' });
316
+ storage.saveEntryQA(req.params.id, existing);
317
+ }
318
+ res.json(result);
319
+ } catch (err) {
320
+ res.status(500).json({ error: err.message });
321
+ }
322
+ });
323
+
324
+ // Independent QA history for an entry
325
+ app.get('/api/entries/:id/qa', (req, res) => {
326
+ res.json(storage.getEntryQA(req.params.id));
327
+ });
328
+
329
+ app.put('/api/entries/:id/qa', (req, res) => {
330
+ storage.saveEntryQA(req.params.id, req.body.qaHistory || []);
331
+ res.json({ ok: true });
332
+ });
333
+
334
+ // Generate HTML topic page for an entry
335
+ app.post('/api/entries/:id/topic-page', async (req, res) => {
336
+ try {
337
+ const entry = storage.getEntry(req.params.id);
338
+ if (!entry) return res.status(404).json({ error: 'entry not found' });
339
+ const allEntries = storage.getAllEntries();
340
+ const related = allEntries.filter(e => {
341
+ if (e.id === entry.id) return false;
342
+ const eTags = new Set(e.tags || []);
343
+ return (entry.tags || []).some(t => eTags.has(t)) || e.subject === entry.subject;
344
+ }).slice(0, 10);
345
+ const html = await llm.generateTopicHTML(entry, related, req.body.qaHistory || [], req.body.existingHTML || '', req.body.requirements || '', req.body.mode || '');
346
+ res.json({ html });
347
+ } catch (err) {
348
+ console.error('[topic-page] Error:', err.message, '\n', err.stack);
349
+ res.status(500).json({ error: err.message });
350
+ }
351
+ });
352
+
353
+ const PORT = process.env.PORT || 3000;
354
+
355
+ // Save topic page (creates new version)
356
+ app.post('/api/entries/:id/topic-page/save', (req, res) => {
357
+ try {
358
+ const { html, qaHistory, comments, includedQaIds } = req.body;
359
+ if (!html) return res.status(400).json({ error: 'html is required' });
360
+ if (html.replace(/<[^>]*>/g, '').trim().length < 50) return res.status(400).json({ error: 'content too short' });
361
+ const page = storage.saveTopicPage(req.params.id, html, qaHistory || [], comments || [], includedQaIds || []);
362
+ res.json(page);
363
+ } catch (err) {
364
+ res.status(500).json({ error: err.message });
365
+ }
366
+ });
367
+
368
+ // Get all topic page versions for an entry
369
+ app.get('/api/entries/:id/topic-pages', (req, res) => {
370
+ const pages = storage.getTopicPages(req.params.id);
371
+ res.json({ pages });
372
+ });
373
+
374
+ // Get latest topic page for an entry
375
+ app.get('/api/entries/:id/topic-page/latest', (req, res) => {
376
+ const page = storage.getLatestTopicPage(req.params.id);
377
+ res.json({ page });
378
+ });
379
+
380
+ // Update comments on a topic page
381
+ app.put('/api/topic-pages/:pageId/comments', (req, res) => {
382
+ try {
383
+ const { comments } = req.body;
384
+ storage.updateTopicPageComments(req.params.pageId, comments || []);
385
+ res.json({ ok: true });
386
+ } catch (err) {
387
+ res.status(500).json({ error: err.message });
388
+ }
389
+ });
390
+
391
+ app.put('/api/topic-pages/:pageId/qa-history', (req, res) => {
392
+ try {
393
+ const { qaHistory } = req.body;
394
+ storage.updateTopicPageQaHistory(req.params.pageId, qaHistory || []);
395
+ res.json({ ok: true });
396
+ } catch (err) {
397
+ res.status(500).json({ error: err.message });
398
+ }
399
+ });
400
+
401
+ // Delete a specific topic page version
402
+ app.delete('/api/entries/:id/topic-page/:version', (req, res) => {
403
+ try {
404
+ const version = parseInt(req.params.version, 10);
405
+ if (!version) return res.status(400).json({ error: 'invalid version' });
406
+ const ok = storage.deleteTopicPageVersion(req.params.id, version);
407
+ res.json({ ok });
408
+ } catch (err) {
409
+ res.status(500).json({ error: err.message });
410
+ }
411
+ });
412
+
413
+ // Get a specific topic page version
414
+ app.get('/api/entries/:id/topic-page/version/:version', (req, res) => {
415
+ try {
416
+ const version = parseInt(req.params.version, 10);
417
+ const page = storage.getTopicPageByVersion(req.params.id, version);
418
+ res.json({ page });
419
+ } catch (err) {
420
+ res.status(500).json({ error: err.message });
421
+ }
422
+ });
423
+
424
+ // Get children of an entry (for deep analysis)
425
+ app.get('/api/entries/:id/children', (req, res) => {
426
+ try {
427
+ const children = storage.getChildren(req.params.id);
428
+ res.json({ children });
429
+ } catch (err) {
430
+ res.status(500).json({ error: err.message });
431
+ }
432
+ });
433
+
434
+ // Expand an entry into sub-nodes using AI
435
+ app.post('/api/entries/:id/expand', async (req, res) => {
436
+ try {
437
+ const entry = storage.getEntry(req.params.id);
438
+ if (!entry) return res.status(404).json({ error: 'entry not found' });
439
+ const topicPage = storage.getLatestTopicPage(req.params.id);
440
+ const qaHistory = (topicPage && topicPage.qa_history) || [];
441
+ const subTopics = await llm.expandEntry(entry, qaHistory);
442
+ console.log(`[expand] ${entry.title}: got ${subTopics.length} sub-topics`);
443
+ const children = [];
444
+ for (const sub of subTopics) {
445
+ const child = storage.addEntry({
446
+ title: sub.title,
447
+ content: sub.content,
448
+ subject: entry.subject,
449
+ tags: [sub.category, entry.title].filter(Boolean),
450
+ source_type: 'deep-analysis',
451
+ source_ref: entry.id,
452
+ parent_id: entry.id,
453
+ });
454
+ children.push(child);
455
+ }
456
+ res.json({ children });
457
+ } catch (err) {
458
+ res.status(500).json({ error: err.message });
459
+ }
460
+ });
461
+
462
+ // Add a single child entry manually
463
+ app.post('/api/entries/:id/children', (req, res) => {
464
+ try {
465
+ const { title, content, tags = [] } = req.body;
466
+ const parent = storage.getEntry(req.params.id);
467
+ if (!parent) return res.status(404).json({ error: 'parent not found' });
468
+ const child = storage.addEntry({
469
+ title, content,
470
+ subject: parent.subject,
471
+ tags,
472
+ source_type: 'deep-analysis',
473
+ source_ref: req.params.id,
474
+ parent_id: req.params.id,
475
+ });
476
+ res.json(child);
477
+ } catch (err) {
478
+ res.status(500).json({ error: err.message });
479
+ }
480
+ });
481
+
482
+ // Settings (prompts — tracked in git)
483
+ const settingsPath = path.join(__dirname, '..', 'config', 'prompts.json');
484
+
485
+ function loadSettings() {
486
+ try { return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); }
487
+ catch { return { subjects: {}, defaultPrompts: {} }; }
488
+ }
489
+ function saveSettings(data) {
490
+ const dir = path.dirname(settingsPath);
491
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
492
+ fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2));
493
+ }
494
+
495
+ app.get('/api/settings', (req, res) => {
496
+ res.json(loadSettings());
497
+ });
498
+
499
+ app.put('/api/settings', (req, res) => {
500
+ saveSettings(req.body);
501
+ res.json({ ok: true });
502
+ });
503
+
504
+ app.get('/api/llm/config', (req, res) => {
505
+ try {
506
+ res.json(llm.loadLLMConfig());
507
+ } catch (err) {
508
+ res.status(500).json({ error: err.message });
509
+ }
510
+ });
511
+
512
+ app.post('/api/llm/config', (req, res) => {
513
+ try {
514
+ llm.saveLLMConfig(req.body);
515
+ res.json({ ok: true });
516
+ } catch (err) {
517
+ res.status(500).json({ error: err.message });
518
+ }
519
+ });
520
+
521
+ app.post('/api/llm/test', async (req, res) => {
522
+ try {
523
+ const { providerName } = req.body;
524
+ const config = llm.loadLLMConfig();
525
+ const providerCfg = config.providers[providerName];
526
+ if (!providerCfg) return res.status(400).json({ error: `Unknown provider: ${providerName}` });
527
+
528
+ if (providerName === 'agent-maestro') {
529
+ const ok = await llm.probeAgentMaestro(config);
530
+ return res.json({ ok, message: ok ? 'Agent Maestro is reachable' : 'Agent Maestro is not reachable' });
531
+ }
532
+
533
+ const result = await llm.callLLM(
534
+ [{ role: 'user', content: 'Reply with exactly: OK' }],
535
+ { maxTokens: 16, providers: [{ name: providerName, ...providerCfg }] }
536
+ );
537
+ res.json({ ok: true, message: `Response: ${result.slice(0, 100)}` });
538
+ } catch (err) {
539
+ res.json({ ok: false, message: err.message });
540
+ }
541
+ });
542
+
543
+ const portalDist = path.join(__dirname, '..', 'portal', 'dist');
544
+ if (fs.existsSync(portalDist)) {
545
+ app.use(express.static(portalDist));
546
+ app.get('*', (req, res) => {
547
+ res.sendFile(path.join(portalDist, 'index.html'));
548
+ });
549
+ }
550
+
551
+ if (require.main === module) {
552
+ app.listen(PORT, () => console.log(`StudyLens server running on http://localhost:${PORT}`));
553
+ }
554
+
555
+ module.exports = app;