From 9cfb7d1c3b0458f7015d2c829d32235bb8d8c259 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 11 Jun 2026 22:51:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=B4=D0=BE=D0=BB=D0=B3?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D1=8C=20=D0=BE?= =?UTF-8?q?=D0=B1=20=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=BA=D0=B5=20(=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Производный профиль (без 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) --- backend/scripts/check-route-auth.js | 9 +- backend/src/controllers/adminController.js | 2 + .../src/controllers/assistantController.js | 101 +++++++++++++++++- .../db/migrations/067_assistant_memory.sql | 13 +++ backend/src/routes/assistant.js | 4 + frontend/js/admin/sections/assistant.js | 2 + frontend/js/assistant.js | 41 ++++++- js/api.js | 4 +- 8 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 backend/src/db/migrations/067_assistant_memory.sql diff --git a/backend/scripts/check-route-auth.js b/backend/scripts/check-route-auth.js index fd60560..bfa62ef 100644 --- a/backend/scripts/check-route-auth.js +++ b/backend/scripts/check-route-auth.js @@ -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'); diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 29ac790..e0be6a2 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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 }); diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 7e9ef3d..5bfbc92 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -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 }; diff --git a/backend/src/db/migrations/067_assistant_memory.sql b/backend/src/db/migrations/067_assistant_memory.sql new file mode 100644 index 0000000..3473e7d --- /dev/null +++ b/backend/src/db/migrations/067_assistant_memory.sql @@ -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); diff --git a/backend/src/routes/assistant.js b/backend/src/routes/assistant.js index cdd28b6..a10d26e 100644 --- a/backend/src/routes/assistant.js +++ b/backend/src/routes/assistant.js @@ -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; diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index 88f845c..215d09e 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -116,6 +116,7 @@ '
Настройки и статистика
' + '' + '' + + '' + '
' + (cfg.chunks || 0) + ' фрагментов
' + '
Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.
' + '
Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '
'; @@ -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'); } diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index 36dc22d..1bf9534 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -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 @@ '' + ''; openBubble( - '
' + faceSVG('happy') + 'Спроси Квантика' + (_chat.length ? '' : '') + '
' + + '
' + faceSVG('happy') + 'Спроси Квантика' + + '' + + (_chat.length ? '' : '') + '
' + '
' + chips + modes + '' + '
Я помню последние ~6 сообщений этого разговора — как рабочая память: что было раньше, понимаю; старое постепенно забывается. «Очистить» — начать с чистого листа.
', {}); @@ -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 '
' + esc(n.text) + '
'; }).join(''); + var body = m.enabled === false + ? '
Персональная память выключена администратором.
' + : '
' + + (prof.length ? '
' + prof.map(function (x) { return '
• ' + x + '
'; }).join('') + '
' : '') + + (notes ? '
Заметки
' + notes : (prof.length ? '' : '
Пока я ничего не запомнил — позанимайся, и здесь появятся слабые темы и заметки.
')) + + ((notes || prof.length) ? '' : '') + + '
'; + openBubble( + '
' + faceSVG('happy') + 'Что я о тебе помню' + + '
' + + body + + '
Память помогает объяснять под тебя. Видна только тебе; учитель видит лишь общие слабые темы.
', {}); + 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(); diff --git a/js/api.js b/js/api.js index 215b3df..683729a 100644 --- a/js/api.js +++ b/js/api.js @@ -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 || {}); }