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 @@ '