feat(assistant): умная память Квантика — свежесть, категории, темы по всем предметам
Память об ученике (1+2+3 из плана), всё строго на русском: - СВЕЖЕСТЬ: эффективный вес заметок с затуханием по времени (полураспад ~31 день), в промпт идут только актуальные (порог по effWeight). Старое тихо тает. - УМНОЕ СЛИЯНИЕ: вместо дедупа по первым 24 символам — стем-токены (русская морфология) + Jaccard; похожие заметки сливаются (вес+, текст освежается), а не плодят дубли. Лимит 18. - КАТЕГОРИИ: экстрактор классифицирует факт (трудность/предпочтение/цель/ сильная сторона/личное), возвращает JSON; запоминаются и сильные стороны/ интересы, не только проблемы. Гард по кириллице — не-русский текст не попадает. - ТРУДНЫЕ ТЕМЫ ПО ВСЕМ ПРЕДМЕТАМ: профиль считает слабые темы из user_answers+ topics (любой предмет, русские названия), объединяя с экзаменом, а не только math9. - UI «Что я о тебе помню»: у заметок русская плашка-категория. Без миграции (колонки kind/weight/updated_at уже есть). Проверено: логика 8/8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '<div class="asst-mem-note"><span>' + esc(n.text) + '</span><button class="asst-mem-x" data-id="' + n.id + '" title="Забыть">×</button></div>'; }).join('');
|
||||
var MEM_CAT = { difficulty: 'трудность', goal: 'цель', preference: 'предпочтение', strength: 'сильная сторона', personal: 'личное', note: 'заметка' };
|
||||
var notes = (m.notes || []).map(function (n) {
|
||||
var cat = MEM_CAT[n.kind] || 'заметка';
|
||||
return '<div class="asst-mem-note"><span><span class="asst-mem-cat">' + esc(cat) + '</span>' + esc(n.text) + '</span><button class="asst-mem-x" data-id="' + n.id + '" title="Забыть">×</button></div>';
|
||||
}).join('');
|
||||
var body = m.enabled === false
|
||||
? '<div class="asst-mem-off">Персональная память выключена администратором.</div>'
|
||||
: '<div class="asst-mem-body">' +
|
||||
|
||||
Reference in New Issue
Block a user