-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
150 lines (130 loc) · 6 KB
/
server.js
File metadata and controls
150 lines (130 loc) · 6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
import express from 'express';
import cors from 'cors';
import Anthropic from '@anthropic-ai/sdk';
import { config } from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
config();
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
const ALLOWED_MODELS = ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'];
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
function getModel(body) {
return ALLOWED_MODELS.includes(body?.model) ? body.model : DEFAULT_MODEL;
}
app.post('/api/validate-key', async (req, res) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required.' });
}
try {
const anthropic = new Anthropic({ apiKey });
await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }],
});
res.json({ valid: true });
} catch (error) {
if (error?.status === 401) {
return res.status(401).json({ error: 'Invalid API key.' });
}
res.status(500).json({ error: 'Failed to validate key.' });
}
});
app.post('/api/explain', async (req, res) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required.' });
}
const anthropic = new Anthropic({ apiKey });
const { concept, parentConcept, rootConcept, existingConcepts } = req.body;
try {
const contextLine = parentConcept
? `The learner is exploring "${rootConcept}" and clicked on "${concept}" within the explanation of "${parentConcept}". Already explored concepts: ${existingConcepts?.join(', ') || 'none'}.`
: `The learner wants to learn about "${concept}".`;
const message = await anthropic.messages.create({
model: getModel(req.body),
max_tokens: 1024,
messages: [{
role: 'user',
content: `${contextLine}
Explain "${concept}" concisely for an interactive learning tool. Return ONLY valid JSON with no markdown formatting:
{
"title": "Short title (2-5 words)",
"summary": "20-word max definition of what this is",
"bullets": ["Detail point 1", "Detail point 2"],
"keyTerms": ["term1", "term2", "term3"]
}
Rules:
- The summary is a single sentence, 20 words MAX. It answers "what is this?" clearly and names 1-2 specific concepts.${parentConcept ? ` Connect it to "${parentConcept}".` : ''} Example: "Mitochondria are organelles that produce ATP through cellular respiration, powering nearly every cell in your body."
- Write 2-5 bullet points that teach the concept in more detail. Each bullet is 1-2 sentences max. Pack each bullet with specific named concepts (proper nouns, named mechanisms, structures, domain terms) — the more clickable terms per bullet, the better.
- IMPORTANT: Identify 10-15 key terms/concepts that appear in your summary AND bullet text. Be very generous — include every distinct concept, proper noun, named mechanism, specific structure, or domain-specific term.
- Key terms must be specific, meaningful concepts (not generic words like "process" or "system")
- Key terms should be standalone concepts someone might want to learn about
- Prefer naming things precisely over describing them generically
- Make explanations engaging and easy to understand
- Return ONLY the JSON object, no other text`
}],
});
const text = message.content[0].type === 'text' ? message.content[0].text : '';
// Try to extract JSON from the response
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in response');
}
const json = JSON.parse(jsonMatch[0]);
res.json(json);
} catch (error) {
console.error('API error:', error);
if (error?.status === 401) {
return res.status(401).json({ error: 'Invalid API key.' });
}
res.status(500).json({ error: 'Failed to generate explanation' });
}
});
app.post('/api/ask', async (req, res) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required.' });
}
const anthropic = new Anthropic({ apiKey });
const { question, nodes } = req.body;
try {
const nodesContext = nodes.map(n =>
`[ID: ${n.id}] "${n.concept}": ${n.summary} | Key points: ${n.bullets.join(' | ')}`
).join('\n');
const message = await anthropic.messages.create({
model: getModel(req.body),
max_tokens: 1024,
messages: [{
role: 'user',
content: `A learner is viewing these concept cards:\n\n${nodesContext}\n\nThey ask: "${question}"\n\nAnswer using the same format as a concept card. Return ONLY valid JSON:\n{"summary": "20-word max direct answer to their question, mentioning specific named concepts", "bullets": ["Detail point 1", "Detail point 2"], "keyTerms": ["term1", "term2"], "targetNodeId": "the ID of the most relevant concept node"}\n\nRules:\n- summary: 20 words max, direct answer with specific named concepts\n- bullets: 2-4 points expanding on the answer with explorable terms\n- keyTerms: 8-12 specific terms from your summary and bullets\n- targetNodeId: the ID of the most relevant concept card`
}],
});
const text = message.content[0].type === 'text' ? message.content[0].text : '';
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in response');
}
const json = JSON.parse(jsonMatch[0]);
res.json(json);
} catch (error) {
console.error('API error:', error);
if (error?.status === 401) {
return res.status(401).json({ error: 'Invalid API key.' });
}
res.status(500).json({ error: 'Failed to answer question' });
}
});
// Serve static files in production
app.use(express.static(join(__dirname, 'dist')));
app.get('*', (req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'));
});
app.listen(PORT, () => {
console.log(`Rabbit Hole server running on http://localhost:${PORT}`);
});