studylens 0.1.1 → 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/README.md +118 -118
- package/bin/studylens.js +7 -7
- package/package.json +1 -1
- package/portal/dist/assets/{index-C94Qe946.js → index-BdS0V2DX.js} +18 -18
- package/portal/dist/index.html +1 -1
- package/server/index.js +555 -555
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;
|