@iprep/server 1.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 +30 -0
- package/package.json +35 -0
- package/public/assets/index-B1cXABmP.css +1 -0
- package/public/assets/index-B9Reldd7.js +45 -0
- package/public/index.html +14 -0
- package/src/app.js +38 -0
- package/src/index.js +31 -0
- package/src/middleware/error-handler.js +9 -0
- package/src/middleware/logger.js +9 -0
- package/src/middleware/validation.js +16 -0
- package/src/routes/chat.js +18 -0
- package/src/routes/conversations.js +43 -0
- package/src/routes/documents.js +172 -0
- package/src/routes/health.js +9 -0
- package/src/routes/tutors.js +107 -0
- package/src/services/chat-service.js +69 -0
- package/src/services/tutor-service.js +53 -0
- package/src/utils/env.js +10 -0
- package/src/utils/logger.js +3 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { logger } from '../utils/logger.js';
|
|
2
|
+
|
|
3
|
+
export function errorHandler(err, req, res, next) {
|
|
4
|
+
logger.error(`${req.method} ${req.path} →`, err.message);
|
|
5
|
+
const status = err.status || err.statusCode || 500;
|
|
6
|
+
res.status(status).json({
|
|
7
|
+
error: status >= 500 ? 'Internal server error' : err.message,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { isValidMessage, isValidTutorId, isValidUserId } from '@iprep/shared';
|
|
2
|
+
|
|
3
|
+
export function validateChatRequest(req, res, next) {
|
|
4
|
+
const { userId, tutorId, message } = req.body;
|
|
5
|
+
|
|
6
|
+
if (!isValidUserId(userId)) {
|
|
7
|
+
return res.status(400).json({ error: 'Invalid or missing userId' });
|
|
8
|
+
}
|
|
9
|
+
if (!isValidTutorId(tutorId)) {
|
|
10
|
+
return res.status(400).json({ error: `Invalid tutorId. Must be one of the registered tutors.` });
|
|
11
|
+
}
|
|
12
|
+
if (!isValidMessage(message)) {
|
|
13
|
+
return res.status(400).json({ error: 'Invalid or missing message' });
|
|
14
|
+
}
|
|
15
|
+
next();
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { sendMessage } from '../services/chat-service.js';
|
|
3
|
+
import { validateChatRequest } from '../middleware/validation.js';
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
// POST /api/chat
|
|
8
|
+
router.post('/', validateChatRequest, async (req, res, next) => {
|
|
9
|
+
try {
|
|
10
|
+
const { userId, tutorId, message, conversationId } = req.body;
|
|
11
|
+
const result = await sendMessage(userId, tutorId, message, conversationId || null);
|
|
12
|
+
res.json(result);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
next(err);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default router;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getConversationsByUser, getConversationById, getMessagesByConversation } from '@iprep/db';
|
|
3
|
+
import { isValidTutorId, isValidUserId } from '@iprep/shared';
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
// GET /api/conversations?userId=&tutorId=
|
|
8
|
+
router.get('/', async (req, res, next) => {
|
|
9
|
+
try {
|
|
10
|
+
const { userId, tutorId } = req.query;
|
|
11
|
+
if (!isValidUserId(userId) || !isValidTutorId(tutorId)) {
|
|
12
|
+
return res.status(400).json({ error: 'userId and tutorId are required' });
|
|
13
|
+
}
|
|
14
|
+
const conversations = await getConversationsByUser(userId, tutorId);
|
|
15
|
+
res.json({ conversations });
|
|
16
|
+
} catch (err) {
|
|
17
|
+
next(err);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// GET /api/conversations/:id
|
|
22
|
+
router.get('/:id', async (req, res, next) => {
|
|
23
|
+
try {
|
|
24
|
+
const conv = await getConversationById(Number(req.params.id));
|
|
25
|
+
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
26
|
+
res.json({ conversation: conv });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
next(err);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// GET /api/conversations/:id/messages
|
|
33
|
+
router.get('/:id/messages', async (req, res, next) => {
|
|
34
|
+
try {
|
|
35
|
+
const convId = Number(req.params.id);
|
|
36
|
+
const messages = await getMessagesByConversation(convId);
|
|
37
|
+
res.json({ messages });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
next(err);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export default router;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import multer from 'multer';
|
|
5
|
+
import { IprepPaths, isValidTutorId } from '@iprep/shared';
|
|
6
|
+
import {
|
|
7
|
+
prisma,
|
|
8
|
+
createDocument,
|
|
9
|
+
getDocumentsByTutor,
|
|
10
|
+
deleteDocument as dbDeleteDocument,
|
|
11
|
+
} from '@iprep/db';
|
|
12
|
+
|
|
13
|
+
const router = Router();
|
|
14
|
+
|
|
15
|
+
const ALLOWED_EXTS = new Set(['.md', '.txt']);
|
|
16
|
+
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
17
|
+
|
|
18
|
+
const upload = multer({
|
|
19
|
+
storage: multer.memoryStorage(),
|
|
20
|
+
limits: { fileSize: MAX_SIZE },
|
|
21
|
+
fileFilter: (_req, file, cb) => {
|
|
22
|
+
const ext = path.extname(file.originalname).toLowerCase();
|
|
23
|
+
if (ALLOWED_EXTS.has(ext)) cb(null, true);
|
|
24
|
+
else cb(new Error(`Unsupported file type: ${ext}`));
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// File formats that don't need conversion — mark success immediately
|
|
29
|
+
const NO_CONVERT_EXTS = new Set(['.md', '.txt']);
|
|
30
|
+
|
|
31
|
+
function formatExt(filename) {
|
|
32
|
+
return path.extname(filename).replace('.', '').toUpperCase() || 'FILE';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function initialStatus(filename) {
|
|
36
|
+
const ext = path.extname(filename).toLowerCase();
|
|
37
|
+
return NO_CONVERT_EXTS.has(ext) ? 'success' : 'pending';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Sync filesystem → DB:
|
|
41
|
+
// 1. Create records for files on disk with no DB entry
|
|
42
|
+
// 2. Fix stale 'pending' status on .md/.txt files that don't need conversion
|
|
43
|
+
async function syncDiskToDB(tutorId) {
|
|
44
|
+
const dir = IprepPaths.documents(tutorId);
|
|
45
|
+
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = await fs.readdir(dir);
|
|
49
|
+
} catch {
|
|
50
|
+
return; // directory doesn't exist yet — nothing to sync
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const existing = await getDocumentsByTutor(tutorId);
|
|
54
|
+
const trackedByName = new Map(existing.map((d) => [d.originalName, d]));
|
|
55
|
+
|
|
56
|
+
for (const filename of entries) {
|
|
57
|
+
const ext = path.extname(filename).toLowerCase();
|
|
58
|
+
if (!ALLOWED_EXTS.has(ext)) continue;
|
|
59
|
+
|
|
60
|
+
const filePath = path.join(dir, filename);
|
|
61
|
+
let stat;
|
|
62
|
+
try { stat = await fs.stat(filePath); } catch { continue; }
|
|
63
|
+
if (!stat.isFile()) continue;
|
|
64
|
+
|
|
65
|
+
const correct = initialStatus(filename);
|
|
66
|
+
const record = trackedByName.get(filename);
|
|
67
|
+
|
|
68
|
+
if (!record) {
|
|
69
|
+
// File exists on disk but no DB record — auto-import it
|
|
70
|
+
await createDocument({
|
|
71
|
+
tutorId,
|
|
72
|
+
originalName: filename,
|
|
73
|
+
originalFormat: formatExt(filename),
|
|
74
|
+
fileSize: stat.size,
|
|
75
|
+
conversionStatus: correct,
|
|
76
|
+
});
|
|
77
|
+
} else if (record.conversionStatus === 'pending' && correct === 'success') {
|
|
78
|
+
// Existing record has wrong status — fix it
|
|
79
|
+
await prisma.document.update({
|
|
80
|
+
where: { id: record.id },
|
|
81
|
+
data: { conversionStatus: 'success' },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// GET /api/documents?tutorId=
|
|
88
|
+
router.get('/', async (req, res, next) => {
|
|
89
|
+
try {
|
|
90
|
+
const { tutorId } = req.query;
|
|
91
|
+
if (!tutorId || !isValidTutorId(tutorId)) {
|
|
92
|
+
return res.status(400).json({ error: 'Valid tutorId is required' });
|
|
93
|
+
}
|
|
94
|
+
// Auto-import any files placed directly on disk without going through upload
|
|
95
|
+
await syncDiskToDB(tutorId);
|
|
96
|
+
const documents = await getDocumentsByTutor(tutorId);
|
|
97
|
+
res.json({ documents });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
next(err);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// POST /api/documents (multipart: tutorId + file)
|
|
104
|
+
router.post('/', upload.single('file'), async (req, res, next) => {
|
|
105
|
+
try {
|
|
106
|
+
const { tutorId } = req.body;
|
|
107
|
+
|
|
108
|
+
if (!tutorId || !isValidTutorId(tutorId)) {
|
|
109
|
+
return res.status(400).json({ error: 'Valid tutorId is required' });
|
|
110
|
+
}
|
|
111
|
+
if (!req.file) {
|
|
112
|
+
return res.status(400).json({ error: 'No file uploaded' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Ensure the documents directory exists
|
|
116
|
+
const destDir = IprepPaths.documents(tutorId);
|
|
117
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
118
|
+
|
|
119
|
+
// Write file to disk
|
|
120
|
+
const destPath = path.join(destDir, req.file.originalname);
|
|
121
|
+
await fs.writeFile(destPath, req.file.buffer);
|
|
122
|
+
|
|
123
|
+
// Create DB record (.md/.txt are already plain text — mark success immediately)
|
|
124
|
+
const document = await createDocument({
|
|
125
|
+
tutorId,
|
|
126
|
+
originalName: req.file.originalname,
|
|
127
|
+
originalFormat: formatExt(req.file.originalname),
|
|
128
|
+
fileSize: req.file.size,
|
|
129
|
+
conversionStatus: initialStatus(req.file.originalname),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
res.status(201).json({ document, message: 'ok' });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
next(err);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// DELETE /api/documents/:docId
|
|
139
|
+
router.delete('/:docId', async (req, res, next) => {
|
|
140
|
+
try {
|
|
141
|
+
const { docId } = req.params;
|
|
142
|
+
|
|
143
|
+
// Fetch record first so we know tutorId + filename
|
|
144
|
+
const doc = await prisma.document.findUnique({ where: { id: docId } });
|
|
145
|
+
|
|
146
|
+
if (!doc) {
|
|
147
|
+
return res.status(404).json({ error: 'Document not found' });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Delete file from disk (ignore if already gone)
|
|
151
|
+
const filePath = path.join(IprepPaths.documents(doc.tutorId), doc.originalName);
|
|
152
|
+
try {
|
|
153
|
+
await fs.unlink(filePath);
|
|
154
|
+
} catch { /* already removed */ }
|
|
155
|
+
|
|
156
|
+
await dbDeleteDocument(docId);
|
|
157
|
+
|
|
158
|
+
res.json({ success: true });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
next(err);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Multer error handler (file size / type rejections)
|
|
165
|
+
router.use((err, _req, res, next) => {
|
|
166
|
+
if (err instanceof multer.MulterError || err.message?.startsWith('Unsupported')) {
|
|
167
|
+
return res.status(400).json({ error: err.message });
|
|
168
|
+
}
|
|
169
|
+
next(err);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
export default router;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { listTutors, loadTutorConfig } from '../services/tutor-service.js';
|
|
5
|
+
import { isValidTutorId, IprepPaths } from '@iprep/shared';
|
|
6
|
+
|
|
7
|
+
const TUTORS_DIR = IprepPaths.aitutors;
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
|
|
11
|
+
// GET /api/tutors
|
|
12
|
+
router.get('/', async (req, res, next) => {
|
|
13
|
+
try {
|
|
14
|
+
const tutors = await listTutors();
|
|
15
|
+
res.json({ tutors });
|
|
16
|
+
} catch (err) {
|
|
17
|
+
next(err);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// GET /api/tutors/:tutorId
|
|
22
|
+
router.get('/:tutorId', async (req, res, next) => {
|
|
23
|
+
try {
|
|
24
|
+
const { tutorId } = req.params;
|
|
25
|
+
if (!isValidTutorId(tutorId)) {
|
|
26
|
+
return res.status(404).json({ error: 'Tutor not found' });
|
|
27
|
+
}
|
|
28
|
+
const config = await loadTutorConfig(tutorId);
|
|
29
|
+
res.json({ tutor: config });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
next(err);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// GET /api/tutors/:tutorId/files — list .md config files for a tutor
|
|
36
|
+
router.get('/:tutorId/files', async (req, res, next) => {
|
|
37
|
+
try {
|
|
38
|
+
const { tutorId } = req.params;
|
|
39
|
+
if (!isValidTutorId(tutorId)) {
|
|
40
|
+
return res.status(404).json({ error: 'Tutor not found' });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tutorDir = path.join(TUTORS_DIR, tutorId);
|
|
44
|
+
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = await fs.readdir(tutorDir);
|
|
48
|
+
} catch {
|
|
49
|
+
return res.json({ files: [] });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const mdFiles = entries.filter(f => f.endsWith('.md'));
|
|
53
|
+
|
|
54
|
+
const files = await Promise.all(
|
|
55
|
+
mdFiles.map(async (filename) => {
|
|
56
|
+
const filePath = path.join(tutorDir, filename);
|
|
57
|
+
const stat = await fs.stat(filePath);
|
|
58
|
+
return {
|
|
59
|
+
filename,
|
|
60
|
+
lastModified: stat.mtime.toISOString(),
|
|
61
|
+
size: stat.size,
|
|
62
|
+
};
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
res.json({ files });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
next(err);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// GET /api/tutors/:tutorId/files/:filename — return raw markdown content
|
|
73
|
+
router.get('/:tutorId/files/:filename', async (req, res, next) => {
|
|
74
|
+
try {
|
|
75
|
+
const { tutorId, filename } = req.params;
|
|
76
|
+
|
|
77
|
+
if (!isValidTutorId(tutorId)) {
|
|
78
|
+
return res.status(404).json({ error: 'Tutor not found' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Security: only allow .md files, block path traversal
|
|
82
|
+
if (!filename.endsWith('.md') || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
83
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const filePath = path.join(TUTORS_DIR, tutorId, filename);
|
|
87
|
+
|
|
88
|
+
// Verify the resolved path is still inside tutorDir (extra safety)
|
|
89
|
+
const tutorDir = path.join(TUTORS_DIR, tutorId);
|
|
90
|
+
if (!filePath.startsWith(tutorDir + path.sep) && filePath !== tutorDir) {
|
|
91
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let content;
|
|
95
|
+
try {
|
|
96
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
97
|
+
} catch {
|
|
98
|
+
return res.status(404).json({ error: 'File not found' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
res.json({ filename, content });
|
|
102
|
+
} catch (err) {
|
|
103
|
+
next(err);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export default router;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getOrCreateSession,
|
|
3
|
+
updateSessionClaudeId,
|
|
4
|
+
createConversation,
|
|
5
|
+
getRecentMessages,
|
|
6
|
+
saveMessage,
|
|
7
|
+
} from '@iprep/db';
|
|
8
|
+
import { sessionManager, buildSystemPrompt, buildChatPrompt } from '@iprep/adapter-utils';
|
|
9
|
+
import { sanitizeMessage } from '@iprep/shared';
|
|
10
|
+
import { loadTutorConfig } from './tutor-service.js';
|
|
11
|
+
import { logger } from '../utils/logger.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Orchestrates sending a message: loads tutor config, spawns/reuses Claude,
|
|
15
|
+
* saves messages to DB, returns the response.
|
|
16
|
+
*/
|
|
17
|
+
export async function sendMessage(userId, tutorId, userMessage, conversationId = null) {
|
|
18
|
+
const message = sanitizeMessage(userMessage);
|
|
19
|
+
|
|
20
|
+
// 1. Load tutor personality from filesystem
|
|
21
|
+
const tutorConfig = await loadTutorConfig(tutorId);
|
|
22
|
+
|
|
23
|
+
// 2. Get or create DB session
|
|
24
|
+
const session = await getOrCreateSession(userId, tutorId);
|
|
25
|
+
|
|
26
|
+
// 3. Get or create conversation
|
|
27
|
+
let convId = conversationId;
|
|
28
|
+
if (!convId) {
|
|
29
|
+
const conv = await createConversation(
|
|
30
|
+
userId,
|
|
31
|
+
session.id,
|
|
32
|
+
tutorId,
|
|
33
|
+
message.slice(0, 60) // Use first 60 chars as title
|
|
34
|
+
);
|
|
35
|
+
convId = conv.id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 4. Load recent history for context
|
|
39
|
+
const history = await getRecentMessages(convId, 10);
|
|
40
|
+
|
|
41
|
+
// 5. Build prompts
|
|
42
|
+
const systemPrompt = buildSystemPrompt(tutorConfig);
|
|
43
|
+
const fullPrompt = buildChatPrompt(systemPrompt, history, message);
|
|
44
|
+
|
|
45
|
+
// 6. Get or spawn Claude process
|
|
46
|
+
const spawner = await sessionManager.getOrCreate(userId, tutorId, session.claudeSessionId);
|
|
47
|
+
|
|
48
|
+
// 7. Save user message first
|
|
49
|
+
await saveMessage(convId, 'user', message);
|
|
50
|
+
|
|
51
|
+
// 8. Send to Claude and get response
|
|
52
|
+
logger.info(`Sending message to Claude for user=${userId} tutor=${tutorId}`);
|
|
53
|
+
const response = await spawner.sendPrompt(fullPrompt);
|
|
54
|
+
|
|
55
|
+
// 9. Save the assistant response
|
|
56
|
+
await saveMessage(convId, 'assistant', response);
|
|
57
|
+
|
|
58
|
+
// 10. Update session with Claude's session ID for continuity
|
|
59
|
+
if (spawner.sessionId && spawner.sessionId !== session.claudeSessionId) {
|
|
60
|
+
await updateSessionClaudeId(session.id, spawner.sessionId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
response,
|
|
65
|
+
tutorId,
|
|
66
|
+
conversationId: convId,
|
|
67
|
+
sessionId: session.id,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { DEFAULT_TUTORS, IprepPaths } from '@iprep/shared';
|
|
4
|
+
import { upsertTutor, getAllTutors } from '@iprep/db';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
const TUTORS_DIR = IprepPaths.aitutors;
|
|
8
|
+
|
|
9
|
+
async function readFile(filePath) {
|
|
10
|
+
try {
|
|
11
|
+
return await fs.readFile(filePath, 'utf-8');
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load a tutor's config from the filesystem (markdown files).
|
|
19
|
+
*/
|
|
20
|
+
export async function loadTutorConfig(tutorId) {
|
|
21
|
+
const dir = path.join(TUTORS_DIR, tutorId);
|
|
22
|
+
const [systemPrompt, skills, settingsRaw] = await Promise.all([
|
|
23
|
+
readFile(path.join(dir, 'system-prompt.md')),
|
|
24
|
+
readFile(path.join(dir, 'skills.md')),
|
|
25
|
+
readFile(path.join(dir, 'settings.md')),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const meta = DEFAULT_TUTORS.find((t) => t.id === tutorId) || { id: tutorId, name: tutorId };
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...meta,
|
|
32
|
+
systemPrompt: systemPrompt || `You are ${meta.name}, a helpful AI tutor.`,
|
|
33
|
+
skills: skills || '',
|
|
34
|
+
settings: settingsRaw || '',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Seed the database with the default tutors if they don't exist yet.
|
|
40
|
+
*/
|
|
41
|
+
export async function seedTutors() {
|
|
42
|
+
for (const tutor of DEFAULT_TUTORS) {
|
|
43
|
+
await upsertTutor(tutor);
|
|
44
|
+
logger.info(`Tutor seeded: ${tutor.name}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* List all tutors from the DB, enriched with filesystem config.
|
|
50
|
+
*/
|
|
51
|
+
export async function listTutors() {
|
|
52
|
+
return getAllTutors();
|
|
53
|
+
}
|
package/src/utils/env.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
// Load .env from ~/.iprep/.env (user's home directory)
|
|
6
|
+
dotenv.config({ path: path.join(os.homedir(), '.iprep', '.env') });
|
|
7
|
+
|
|
8
|
+
export const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
9
|
+
export const NODE_ENV = process.env.NODE_ENV || 'development';
|
|
10
|
+
export const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
|