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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user