Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources generates better slides than Learning Hub ever did — a typed deck the model fills in, rendered by python-pptx with fit-to-slide text, figures, a vision review and themes, against Learning Hub's markdown-through-pandoc — and the articles and quizzes now live in the quiz app. Keeping a second, weaker generator and a whole CMS beside it was not earning its maintenance. Removed: three routers, the Learning Hub and Content Manager tabs, their components and frontend modules, the five database tables, the WebDAV browser, the content embedding column and its vector index. Content was exported first — every article as markdown plus a full SQL dump of all five tables — to ops-backups/learning-hub-export-*. That export is the restore path; the migration's down() can recreate the shape but never the rows, and says so. Two things this simplifies rather than merely deletes: generated_image_links existed only to record which published content an image appeared in, and it was the sole reason a generated image could be read by someone who did not make it. Images are now owner-only — the visibility rule is one WHERE clause instead of a join across two tables and a published flag. embeddings.js keeps the model discovery the admin panel uses and loses searchSimilar and generateContentEmbedding, which queried a table that no longer exists. Kept deliberately: Nextcloud connect, disconnect and export, which are how a generated note reaches a real filesystem and have nothing to do with Learning Hub; learningRetrieval, which despite its name is the clinical corpus search My Resources depends on; and the pandoc reference deck, still the fallback when the python renderer fails, moved from assets/learning to assets/deck now that the old name misleads. Tests: four Learning-Hub-only files removed, and the individual cases inside shared files that asserted its behaviour. Where a test used a Learning endpoint only as a convenient example — the account-boundary token test, the policy matrix — it now uses one that still exists, so the property it proves is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
128 lines
6.3 KiB
JavaScript
128 lines
6.3 KiB
JavaScript
// ============================================================
|
|
// MEMORIES ROUTES — User template/memory storage
|
|
// ============================================================
|
|
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
var db = require('../db/database');
|
|
var { authMiddleware } = require('../middleware/auth');
|
|
var logger = require('../utils/logger');
|
|
var cryptoUtil = require('../utils/crypto');
|
|
|
|
// Scoped to this router's own prefix. These routers are mounted on /api, so a
|
|
// bare router.use(authMiddleware) gated every /api path — including routes
|
|
// belonging to routers mounted after it. That is what kept the signed-out
|
|
// assistant preview returning 401 no matter what the admin setting said.
|
|
router.use('/memories', authMiddleware);
|
|
router.use('/memories', require('../utils/policy').requireFeature('memories'));
|
|
|
|
// Decrypt a row's user-facing fields. Safe against legacy plaintext rows —
|
|
// cryptoUtil.decryptString passes through values without the "enc1:" prefix.
|
|
function decryptMemory(row) {
|
|
if (!row) return row;
|
|
try { row.name = cryptoUtil.decryptString(row.name); } catch (e) {}
|
|
try { row.content = cryptoUtil.decryptString(row.content); } catch (e) {}
|
|
return row;
|
|
}
|
|
|
|
var VALID_CATEGORIES = [
|
|
'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom',
|
|
'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit', 'template_ed'
|
|
];
|
|
|
|
var AI_CONTEXT_CATEGORIES = [
|
|
'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan',
|
|
'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit', 'template_ed'
|
|
];
|
|
|
|
// ── GET all memories for current user ───────────────────────────────────
|
|
router.get('/memories', async function(req, res) {
|
|
try {
|
|
// Legacy correction_* rows from the removed Dragon-style correction feature
|
|
// are filtered out — invisible to UI and to the AI context endpoint, but
|
|
// not dropped from the table.
|
|
var rows = await db.all(
|
|
"SELECT id, category, name, content, created_at, updated_at FROM user_memories WHERE user_id = $1 AND category NOT LIKE 'correction_%' ORDER BY category, id",
|
|
[req.user.id]
|
|
);
|
|
rows.forEach(decryptMemory);
|
|
res.json({ success: true, memories: rows });
|
|
} catch (e) { logger.error('GET /memories', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── POST create memory ───────────────────────────────────────────────────
|
|
router.post('/memories', async function(req, res) {
|
|
try {
|
|
var { name, category, content } = req.body;
|
|
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
|
if (!content || !content.trim()) return res.status(400).json({ error: 'Content required' });
|
|
var cat = VALID_CATEGORIES.includes(category) ? category : 'custom';
|
|
|
|
// Limit per user
|
|
var count = await db.get('SELECT COUNT(*) as cnt FROM user_memories WHERE user_id = $1', [req.user.id]);
|
|
if (count && parseInt(count.cnt) >= 200) return res.status(400).json({ error: 'Maximum 200 memories per user' });
|
|
|
|
var result = await db.run(
|
|
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
|
[
|
|
req.user.id,
|
|
cat,
|
|
cryptoUtil.encryptString(name.trim().substring(0, 100)),
|
|
cryptoUtil.encryptString(content.trim().substring(0, 5000))
|
|
]
|
|
);
|
|
res.json({ success: true, id: result.lastInsertRowid });
|
|
} catch (e) { logger.error('POST /memories', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── PUT update memory ────────────────────────────────────────────────────
|
|
router.put('/memories/:id', async function(req, res) {
|
|
try {
|
|
var { name, category, content } = req.body;
|
|
var cat = VALID_CATEGORIES.includes(category) ? category : 'custom';
|
|
await db.run(
|
|
'UPDATE user_memories SET name=$1, category=$2, content=$3, updated_at=NOW() WHERE id=$4 AND user_id=$5',
|
|
[
|
|
cryptoUtil.encryptString((name || '').trim().substring(0, 100)),
|
|
cat,
|
|
cryptoUtil.encryptString((content || '').trim().substring(0, 5000)),
|
|
req.params.id,
|
|
req.user.id
|
|
]
|
|
);
|
|
res.json({ success: true });
|
|
} catch (e) { logger.error('PUT /memories/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── DELETE memory ────────────────────────────────────────────────────────
|
|
router.delete('/memories/:id', async function(req, res) {
|
|
try {
|
|
await db.run('DELETE FROM user_memories WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
|
res.json({ success: true });
|
|
} catch (e) { logger.error('DELETE /memories/:id', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── GET memories as prompt context (for AI generation) ──────────────────
|
|
// Returns user templates concatenated as system-prompt context. Multiple
|
|
// templates per category are allowed; we order by id ASC so the newest
|
|
// row lands last in the prompt — models weight later context more heavily,
|
|
// which gives the most recently saved template implicit predominance.
|
|
router.get('/memories/context', async function(req, res) {
|
|
try {
|
|
var rows = await db.all(
|
|
"SELECT category, name, content FROM user_memories WHERE user_id = $1 AND category NOT LIKE 'correction_%' ORDER BY category, id",
|
|
[req.user.id]
|
|
);
|
|
rows.forEach(decryptMemory);
|
|
rows = rows.filter(function(r) { return AI_CONTEXT_CATEGORIES.indexOf(r.category) !== -1; });
|
|
if (rows.length === 0) return res.json({ success: true, context: '' });
|
|
|
|
var context = '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
|
|
rows.forEach(function(r) {
|
|
context += '--- ' + r.category.toUpperCase().replace(/_/g, ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
|
|
});
|
|
res.json({ success: true, context: context.trim() });
|
|
} catch (e) { logger.error('GET /memories/context', e.message); res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
module.exports = router;
|