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
+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 };