diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index b56f8a8..258b5ca 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -165,13 +165,31 @@ function _studentProfile(uid) { `).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) })); + const cand = {}; // трудные темы по ВСЕМ предметам: банк тестов + экзамен + try { + db.prepare(` + SELECT t.name AS topic, COUNT(*) AS attempts, SUM(ua.is_correct) AS correct + FROM user_answers ua JOIN questions q ON q.id = ua.question_id JOIN topics t ON t.id = q.topic_id + WHERE ua.session_id IN (SELECT id FROM test_sessions WHERE user_id = ? AND status = 'completed') + GROUP BY q.topic_id HAVING attempts >= 3 + `).all(uid).forEach(r => { cand[r.topic] = { topic: r.topic, attempts: r.attempts, correct: r.correct || 0 }; }); + } catch (e) {} + try { + 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 + `).all(uid).forEach(r => { + const c = cand[r.topic]; + if (c) { c.attempts += r.attempts; c.correct += (r.correct || 0); } + else cand[r.topic] = { topic: r.topic, attempts: r.attempts, correct: r.correct || 0 }; + }); + } catch (e) {} + out.weakTopics = Object.values(cand) + .map(c => ({ topic: c.topic, rate: Math.round(c.correct * 100 / c.attempts) })) + .filter(x => x.rate < 60) + .sort((a, b) => a.rate - b.rate).slice(0, 4); } 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); @@ -190,34 +208,80 @@ function _memoryBlock(uid) { 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('; ')); + const rows = db.prepare('SELECT kind, text, weight, updated_at FROM assistant_memory WHERE user_id = ?').all(uid); + rows.forEach(r => { r.eff = _effWeight(r.weight, r.updated_at); }); + const top = rows.filter(r => r.eff >= 0.25).sort((a, b) => b.eff - a.eff).slice(0, 8); + const LBL = { difficulty: 'трудности', goal: 'цели', preference: 'предпочтения', strength: 'сильные стороны', personal: 'о себе', note: 'заметки' }; + const byKind = {}; + top.forEach(r => { (byKind[r.kind] || (byKind[r.kind] = [])).push(r.text); }); + Object.keys(byKind).forEach(k => parts.push((LBL[k] || 'заметки') + ': ' + byKind[k].join('; '))); } catch (e) {} return parts.join('; '); } -// Upsert заметки с дедупликацией и лимитом. +// Стем-токены для сравнения заметок (русская морфология: «дробях»→«дроб»). +function _memTokens(text) { + const stem = (w) => (w.length >= 7 ? w.slice(0, Math.max(4, w.length - 3)) : w.length >= 5 ? w.slice(0, Math.max(4, w.length - 2)) : w); + return Array.from(new Set(String(text || '').toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).map(stem))); +} +function _jaccard(a, b) { + if (!a.length || !b.length) return 0; + const sb = new Set(b); let inter = 0; + a.forEach(t => { if (sb.has(t)) inter++; }); + return inter / (a.length + b.length - inter); +} +// Эффективный вес с затуханием по времени (полураспад ~31 день) — память остаётся свежей. +function _effWeight(weight, updatedAt) { + let days = 0; + try { days = (Date.now() - new Date(String(updatedAt).replace(' ', 'T') + 'Z').getTime()) / 86400000; } catch (e) {} + if (!(days > 0)) days = 0; + return weight * Math.exp(-days / 45); +} + +// 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); + text = String(text).trim().slice(0, 200); + const toks = _memTokens(text); + if (!toks.length) return; + const rows = db.prepare('SELECT id, text FROM assistant_memory WHERE user_id = ?').all(uid); + for (const r of rows) { + if (_jaccard(toks, _memTokens(r.text)) >= 0.5) { // та же мысль — слить, освежить, поднять вес + db.prepare("UPDATE assistant_memory SET weight = weight + 0.5, text = ?, kind = ?, updated_at = datetime('now') WHERE id = ?").run(text, kind, r.id); + return; + } + } + db.prepare("INSERT INTO assistant_memory (user_id, kind, text, weight, source) VALUES (?, ?, ?, ?, ?)").run(uid, kind, text, 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); + if (cnt > 18) 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 - 18); } catch (e) {} } // Экстрактор: 1 устойчивый факт об ученике из реплики+ответа (фоновый, дросселированный). +const _MEM_CATS = new Set(['difficulty', 'preference', 'goal', 'strength', 'personal']); +// Доля кириллицы среди букв — гард, чтобы в память не попадал не-русский текст. +function _cyrShare(s) { + const letters = (String(s).match(/[a-zа-яё]/gi) || []).length; + const cyr = (String(s).match(/[а-яё]/gi) || []).length; + return letters ? cyr / letters : 0; +} 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'); + const sys = 'Ты ведёшь короткие заметки об ученике для персонализации обучения. ' + + 'По вопросу ученика и ответу выдели ОДИН устойчивый факт: что даётся ТРУДНО (difficulty), ПРЕДПОЧТЕНИЕ в обучении (preference), ЦЕЛЬ (goal), СИЛЬНАЯ сторона (strength) или ЛИЧНОЕ — класс/интересы (personal). ' + + 'Верни СТРОГО JSON {"cat":"difficulty|preference|goal|strength|personal","text":"<факт до 12 слов>"} ' + + 'и текст ИСКЛЮЧИТЕЛЬНО НА РУССКОМ ЯЗЫКЕ, без кавычек внутри. Если устойчивого факта нет — верни {"cat":"none"}.'; + const r = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: `Вопрос: ${q}\nОтвет: ${String(answer).slice(0, 500)}` }], 60); + let cat = 'note', note = ''; + const raw = r && r.text ? r.text.replace(/```(?:json)?/gi, '').trim() : ''; + try { + const a = raw.indexOf('{'), b = raw.lastIndexOf('}'); + const j = a >= 0 && b > a ? JSON.parse(raw.slice(a, b + 1)) : null; + if (j) { cat = String(j.cat || '').toLowerCase(); note = String(j.text || '').trim().replace(/^["'«»]+|["'«»]+$/g, ''); } + } catch (e) { /* не-JSON */ } + if (/^none\b/i.test(cat) || !note || note.length < 5 || note.length > 140) return; + if (_cyrShare(note) < 0.6) return; // не русский — не запоминаем + _memUpsert(uid, _MEM_CATS.has(cat) ? cat : 'note', note, 1, 'extractor'); } catch (e) {} } diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index 7bb3899..5cb7767 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -375,6 +375,7 @@ '.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-cat{display:inline-block;font-size:.6rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em;color:#7e3eca;background:rgba(155,93,229,.1);border-radius:99px;padding:1px 7px;margin-right:7px;vertical-align:1px;}', '.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;}', @@ -625,7 +626,11 @@ 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 MEM_CAT = { difficulty: 'трудность', goal: 'цель', preference: 'предпочтение', strength: 'сильная сторона', personal: 'личное', note: 'заметка' }; + var notes = (m.notes || []).map(function (n) { + var cat = MEM_CAT[n.kind] || 'заметка'; + return '
' + esc(cat) + '' + esc(n.text) + '
'; + }).join(''); var body = m.enabled === false ? '
Персональная память выключена администратором.
' : '
' +