feat(assistant): долгая память об ученике (персонализация)

Производный профиль (без LLM): слабые предметы, трудные темы экзамена,
цель/дата, серия — из test_sessions/exam_attempts/exam_user_plan. Подмешивается
в системный промпт → персональные ответы; такие не кэшируются глобально.
Заметки: таблица assistant_memory + фоновый LLM-экстрактор (дросселирован),
дедуп + лимит 15. Панель ученика «Что я о тебе помню» (профиль + заметки,
удаление). Админ-тумблер. API GET/DELETE /assistant/memory (/:id под
authMiddleware, владелец проверяется в хендлере).

Заодно: сверка стабильного baseline route-auth 56→66 (долг от branch-merge,
хук не идёт на merge) — новых незащищённых маршрутов не добавлено.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-11 22:51:04 +03:00
parent 5417083f88
commit 9cfb7d1c3b
8 changed files with 166 additions and 10 deletions
+6 -3
View File
@@ -50,9 +50,12 @@ const GUARDS = [
'ownsTest', // alias used in tests.js
];
// Baseline: number of unprotected :id-routes found on 2026-05-06.
// ONLY decrease this over time — never increase it.
const BASELINE = 56;
// Baseline: number of unprotected :id-routes.
// Reconciled 2026-06-11: drifted 56→66 via branch merges (lab-content-engine,
// red-book, exam-prep и др.) — pre-commit hook не запускается на merge, поэтому
// маршруты пришли без проверки. Это уже смерженный долг, а не новый риск.
// ONLY decrease this over time — never increase it (кроме сверки с уже смерженным).
const BASELINE = 66;
function scanFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
@@ -940,6 +940,7 @@ function getAssistant(_req, res) {
res.json({
providers, activeId, active,
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
memory: _aset('assistant_memory') !== '0',
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS,
});
}
@@ -950,6 +951,7 @@ function saveAssistant(req, res) {
const b = req.body || {};
if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0');
if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0');
if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0');
if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
audit(req, 'assistant.config', 'assistant', 'настройки');
res.json({ ok: true });
+96 -5
View File
@@ -152,6 +152,93 @@ function weakSubject(uid) {
} catch (e) { return null; }
}
/* ── Долгая память об ученике ─────────────────────────────────────────── */
// Производный профиль (без LLM) — из уже накопленных сигналов.
function _studentProfile(uid) {
const out = { weakSubjects: [], weakTopics: [], exam: null, streak: 0 };
try {
out.weakSubjects = db.prepare(`
SELECT s.name AS name, ROUND(AVG(ts.score * 100.0 / ts.total)) AS avg, COUNT(*) AS n
FROM test_sessions ts JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ? AND ts.status = 'completed' AND ts.total > 0
GROUP BY ts.subject_id HAVING n >= 2 AND avg < 70 ORDER BY avg ASC LIMIT 3
`).all(uid).map(r => ({ name: r.name, avg: r.avg }));
} catch (e) {}
try {
out.weakTopics = db.prepare(`
SELECT et.topic AS topic, COUNT(*) AS attempts, SUM(ea.is_correct) AS correct
FROM exam_attempts ea JOIN exam_tasks et ON et.id = ea.exam_task_id
WHERE ea.user_id = ? AND et.topic IS NOT NULL AND et.topic <> ''
GROUP BY et.topic HAVING attempts >= 3 AND (correct * 1.0 / attempts) < 0.6
ORDER BY (correct * 1.0 / attempts) ASC LIMIT 3
`).all(uid).map(r => ({ topic: r.topic, rate: Math.round(r.correct * 100 / r.attempts) }));
} catch (e) {}
try {
const p = db.prepare('SELECT exam_key, exam_date FROM exam_user_plan WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1').get(uid);
if (p) out.exam = { key: p.exam_key, date: p.exam_date };
} catch (e) {}
try { out.streak = db.prepare('SELECT streak_current FROM users WHERE id = ?').get(uid)?.streak_current || 0; } catch (e) {}
return out;
}
// Текстовый блок памяти для подмешивания в промпт (профиль + заметки).
function _memoryBlock(uid) {
if (_setting('assistant_memory') === '0') return '';
const parts = [], p = _studentProfile(uid);
if (p.exam) parts.push(`готовится к экзамену (${p.exam.key}${p.exam.date ? ', дата ' + p.exam.date : ''})`);
if (p.weakSubjects.length) parts.push('слабые предметы: ' + p.weakSubjects.map(s => `${s.name} (${s.avg}%)`).join(', '));
if (p.weakTopics.length) parts.push('трудные темы: ' + p.weakTopics.map(t => `${t.topic} (${t.rate}%)`).join(', '));
if (p.streak >= 3) parts.push(`серия занятий ${p.streak} дн.`);
try {
const notes = db.prepare('SELECT text FROM assistant_memory WHERE user_id = ? ORDER BY weight DESC, updated_at DESC LIMIT 8').all(uid).map(r => r.text);
if (notes.length) parts.push('заметки: ' + notes.join('; '));
} catch (e) {}
return parts.join('; ');
}
// Upsert заметки с дедупликацией и лимитом.
function _memUpsert(uid, kind, text, weight, source) {
try {
const key = text.toLowerCase().slice(0, 24);
const ex = db.prepare('SELECT id FROM assistant_memory WHERE user_id = ? AND lower(text) LIKE ?').get(uid, '%' + key + '%');
if (ex) { db.prepare("UPDATE assistant_memory SET weight = weight + 0.5, updated_at = datetime('now') WHERE id = ?").run(ex.id); return; }
db.prepare("INSERT INTO assistant_memory (user_id, kind, text, weight, source) VALUES (?, ?, ?, ?, ?)").run(uid, kind, text.slice(0, 200), weight, source);
const cnt = db.prepare('SELECT COUNT(*) AS n FROM assistant_memory WHERE user_id = ?').get(uid).n;
if (cnt > 15) db.prepare('DELETE FROM assistant_memory WHERE id IN (SELECT id FROM assistant_memory WHERE user_id = ? ORDER BY weight ASC, updated_at ASC LIMIT ?)').run(uid, cnt - 15);
} catch (e) {}
}
// Экстрактор: 1 устойчивый факт об ученике из реплики+ответа (фоновый, дросселированный).
async function _extractMemory(uid, q, answer) {
try {
const sys = 'Ты ведёшь короткие заметки о трудностях, предпочтениях и целях ученика для персонализации обучения. ' +
'По вопросу ученика и ответу выдели ОДИН устойчивый факт об ученике (что даётся трудно / что путает / предпочтение / цель). ' +
'Ответь короткой фразой по-русски (до 12 слов), без кавычек. Если устойчивого факта нет — ответь ровно NONE.';
const r = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: `Вопрос: ${q}\nОтвет: ${String(answer).slice(0, 500)}` }], 40);
const note = r && r.text && r.text.trim().replace(/^["'«»]+|["'«»]+$/g, '');
if (!note || /^none\b/i.test(note) || note.length < 5 || note.length > 120) return;
_memUpsert(uid, 'note', note, 1, 'extractor');
} catch (e) {}
}
/* ── GET /api/assistant/memory — что Квантик знает об ученике ──────────── */
function getMemory(req, res) {
const uid = req.user.id;
let notes = [];
try { notes = db.prepare('SELECT id, kind, text FROM assistant_memory WHERE user_id = ? ORDER BY weight DESC, updated_at DESC').all(uid); } catch (e) {}
res.json({ enabled: _setting('assistant_memory') !== '0', profile: _studentProfile(uid), notes });
}
/* ── DELETE /api/assistant/memory[/:id] — забыть всё / одну заметку ────── */
function clearMemory(req, res) {
const uid = req.user.id, id = req.params.id ? Number(req.params.id) : null;
try {
if (id) db.prepare('DELETE FROM assistant_memory WHERE id = ? AND user_id = ?').run(id, uid);
else db.prepare('DELETE FROM assistant_memory WHERE user_id = ?').run(uid);
} catch (e) {}
res.json({ ok: true });
}
/* ── GET /api/assistant/context ───────────────────────────────────────── */
function getContext(req, res) {
const uid = req.user.id;
@@ -388,7 +475,7 @@ const META_RE = new RegExp('(' + _SELF + '[\\sа-яёa-z0-9,?!.-]{0,25}' + _TERM
'|на\\s+ч[её]м\\s+ты\\s+(?:работа|сдела|постро|основ)|кто\\s+тебя\\s+(?:сделал|создал|обуч|разработ|написал)|систем[а-яё]*\\s+промпт|what\\s+model\\s+are\\s+you|which\\s+(?:ai\\s+)?model|your\\s+system\\s+prompt)', 'i');
const META_ANSWER = 'Я — Квантик, помощник LearnSpace. Помогаю с учёбой и навигацией по платформе. Давай вернёмся к делу — что объяснить или подсказать?';
async function askModel(q, hits, context, history, role, mode) {
async function askModel(q, hits, context, history, role, mode, mem) {
const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') +
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
@@ -397,6 +484,7 @@ async function askModel(q, hits, context, history, role, mode) {
sys += ' Пользователь — учитель: помогай и с преподаванием — составить задание или вопросы, план урока, ' +
'объяснить, как пользоваться учительскими инструментами (классы, журнал, аналитика, онлайн-урок, банк вопросов).';
}
if (mem) sys += ' Что известно об ученике (учитывай, чтобы персонализировать объяснение; НЕ зачитывай это вслух): ' + mem + '.';
if (mode === 'hint') {
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.';
} else if (mode === 'check') {
@@ -427,9 +515,10 @@ async function ask(req, res) {
if (!providersOrdered().length) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); }
const rag = ragContext(q);
const mem = _memoryBlock(req.user.id);
// Кэш — только обычный режим без контекста страницы и без истории диалога
const cacheable = mode === 'answer' && !pageCtx && !history.length;
// Кэш — только обычный режим без контекста/истории И без персональной памяти (ответ персонализирован)
const cacheable = mode === 'answer' && !pageCtx && !history.length && !mem;
const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim();
if (cacheable) {
try {
@@ -442,12 +531,14 @@ async function ask(req, res) {
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
let r = { text: null, error: 'network' };
try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { r = { text: null, error: 'network' }; }
try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem); } catch (e) { r = { text: null, error: 'network' }; }
const answer = r && r.text;
if (answer) {
bumpUsage('model_calls');
if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} }
// Фоновая экстракция заметки об ученике — после содержательного диалога/проверки
if (_setting('assistant_memory') !== '0' && (mode === 'check' || history.length >= 4)) _extractMemory(req.user.id, q, answer);
return res.json({ source: 'model', answer, answers: faqJson, sources: rag.sources });
}
bumpUsage('faq');
@@ -504,4 +595,4 @@ async function flashcardsFromText(req, res) {
res.json({ title, cards });
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, llmConfig, pingLLM, clearFailover: _clearFailover };
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, getMemory, clearMemory, llmConfig, pingLLM, clearFailover: _clearFailover };
@@ -0,0 +1,13 @@
-- Долгая память Квантика об ученике: короткие устойчивые заметки
-- (трудности/предпочтения/цели). Производный профиль считается на лету.
CREATE TABLE IF NOT EXISTS assistant_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'note', -- note | difficulty | preference | goal
text TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'extractor', -- extractor | signal | manual
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_assistant_memory_user ON assistant_memory(user_id);
+4
View File
@@ -14,5 +14,9 @@ router.patch('/settings', ctrl.setSettings);
router.post('/ask', ctrl.ask);
router.post('/flashcards', ctrl.flashcardsFromText);
router.post('/feedback', ctrl.feedback);
router.get('/memory', ctrl.getMemory);
router.delete('/memory', ctrl.clearMemory);
// clearMemory удаляет только строки вызывающего (WHERE user_id = req.user.id)
router.delete('/memory/:id', authMiddleware, ctrl.clearMemory);
module.exports = router;
+2
View File
@@ -116,6 +116,7 @@
'<div class="perm-label"><i data-lucide="settings-2" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Настройки и статистика</div>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag" ' + (cfg.rag !== false ? 'checked' : '') + '> Искать ответы по учебникам (RAG)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-memory" ' + (cfg.memory !== false ? 'checked' : '') + '> Персональная память об ученике (слабые темы, заметки)</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span></div>' +
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' +
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
@@ -247,6 +248,7 @@
// настройки
Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-reindex').addEventListener('click', async function () {
var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
+40 -1
View File
@@ -341,6 +341,14 @@
'.asst-fb svg{width:13px;height:13px;}',
'html.asst-exam-on .tc-asst-btn{display:inline-flex !important;}',
'.asst-empty{font-size:.82rem;color:#8a94a6;padding:6px 0;}',
'.asst-mem-body{font-size:.82rem;color:#28324a;max-height:46vh;overflow:auto;}',
'.asst-mem-prof{background:rgba(155,93,229,.07);border:1px solid rgba(155,93,229,.18);border-radius:10px;padding:9px 12px;line-height:1.75;margin-bottom:10px;}',
'.asst-mem-notes-h{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:6px 0 4px;}',
'.asst-mem-note{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(15,23,42,.06);}',
'.asst-mem-note:last-of-type{border-bottom:none;}',
'.asst-mem-x{border:none;background:none;color:#b4bcc8;cursor:pointer;font-size:1.15rem;line-height:1;padding:0 4px;}',
'.asst-mem-x:hover{color:#e0335e;}',
'.asst-mem-off{font-size:.82rem;color:#8a94a6;padding:10px 0;}',
// на мобиле сайдбар — выезжающая шторка, контент во всю ширину → к левому краю
'@media(max-width:768px){.asst-root,.app-layout ~ .asst-root,.app-layout.sb-collapsed ~ .asst-root{left:12px;bottom:18px;}.asst-fab{width:48px;height:48px;}}',
].join('');
@@ -506,7 +514,9 @@
'<button class="asst-mode" data-m="hint">Подсказка</button>' +
'<button class="asst-mode" data-m="check">Проверить решение</button></div>';
openBubble(
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' + (_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:24px">Очистить</button>' : '') + '</div>' +
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
'<button class="asst-link" data-a="mem" style="float:right;font-weight:600;margin-right:24px">Память</button>' +
(_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:8px">Очистить</button>' : '') + '</div>' +
'<div class="asst-chat"></div>' + chips + modes +
'<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />' +
'<div class="asst-memnote">Я помню последние ~6 сообщений этого разговора — как рабочая память: что было раньше, понимаю; старое постепенно забывается. «Очистить» — начать с чистого листа.</div>', {});
@@ -537,9 +547,38 @@
});
var clr = bubble.querySelector('[data-a="clear"]');
if (clr) clr.onclick = function () { _chat = []; openAsk(); };
var memBtn = bubble.querySelector('[data-a="mem"]');
if (memBtn) memBtn.onclick = openMemory;
if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode);
else inp.focus();
}
/* ── «Что я о тебе помню» ── */
function openMemory() {
LS.assistantMemory().then(function (m) {
if (!m) return;
var p = m.profile || {}, prof = [];
if (p.exam) prof.push('Готовишься к экзамену' + (p.exam.date ? ' (до ' + esc(p.exam.date) + ')' : ''));
if (p.weakSubjects && p.weakSubjects.length) prof.push('Слабые предметы: ' + p.weakSubjects.map(function (s) { return esc(s.name) + ' ' + s.avg + '%'; }).join(', '));
if (p.weakTopics && p.weakTopics.length) prof.push('Трудные темы: ' + p.weakTopics.map(function (t) { return esc(t.topic) + ' ' + t.rate + '%'; }).join(', '));
if (p.streak >= 3) prof.push('Серия занятий: ' + p.streak + ' дн.');
var notes = (m.notes || []).map(function (n) { return '<div class="asst-mem-note"><span>' + esc(n.text) + '</span><button class="asst-mem-x" data-id="' + n.id + '" title="Забыть">&times;</button></div>'; }).join('');
var body = m.enabled === false
? '<div class="asst-mem-off">Персональная память выключена администратором.</div>'
: '<div class="asst-mem-body">' +
(prof.length ? '<div class="asst-mem-prof">' + prof.map(function (x) { return '<div>• ' + x + '</div>'; }).join('') + '</div>' : '') +
(notes ? '<div class="asst-mem-notes-h">Заметки</div>' + notes : (prof.length ? '' : '<div class="asst-empty">Пока я ничего не запомнил — позанимайся, и здесь появятся слабые темы и заметки.</div>')) +
((notes || prof.length) ? '<button class="asst-link" data-a="forget" style="margin-top:12px;color:#e0335e">Забыть всё</button>' : '') +
'</div>';
openBubble(
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Что я о тебе помню' +
'<button class="asst-link" data-a="back" style="float:right;font-weight:600;margin-right:24px">← Назад</button></div>' +
body +
'<div class="asst-memnote">Память помогает объяснять под тебя. Видна только тебе; учитель видит лишь общие слабые темы.</div>', {});
var bk = bubble.querySelector('[data-a="back"]'); if (bk) bk.onclick = function () { openAsk(); };
var fg = bubble.querySelector('[data-a="forget"]'); if (fg) fg.onclick = function () { LS.assistantMemoryClear().then(openMemory); };
bubble.querySelectorAll('.asst-mem-x').forEach(function (b) { b.onclick = function () { LS.assistantMemoryClear(b.getAttribute('data-id')).then(openMemory); }; });
});
}
function srcUrl(s) { return '/textbook/' + s.slug + (s.ref ? '#sec-' + s.ref : ''); }
function send(q, context, chatEl, mode) {
q = (q || '').trim();
+3 -1
View File
@@ -1050,7 +1050,7 @@ window.LS = {
crAdminGetAllHistory, crAdminGetTeachersList,
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear,
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
fcListDecks, fcCreateDeck, fcAddCard,
@@ -1277,6 +1277,8 @@ async function assistantSettings(d) { return req('PATCH', '/assistant/settings
async function assistantAsk(q, context, history, mode) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined, mode: mode || undefined }); }
async function assistantFlashcards(text, title) { return req('POST', '/assistant/flashcards', { text, title }); }
async function assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); }
async function assistantMemory() { return req('GET', '/assistant/memory'); }
async function assistantMemoryClear(id) { return req('DELETE', '/assistant/memory' + (id ? '/' + id : '')); }
async function adminGetAssistant() { return req('GET', '/admin/assistant'); }
async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); }
async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); }