feat(assistant): Квантик-ассистент — Ф0/Ф1 + «Спроси» (правиловый движок)

Плавающий помощник на всех страницах (через sidebar.js + inject в учебник):
контекстные подсказки по странице, проактивные напоминания из реальных данных
(домашка с дедлайном, карточки к повторению, серия под угрозой, квест дня),
поздравления (левелап/серия) и панель «Спроси Квантика» (поиск по FAQ + точка
расширения под локальную модель). Консервативно: дневной лимит, кулдауны,
«не показывать», выключатель в профиле. Лицо — pet-sprite, данные — /api/pet.

Бэкенд: миграция 062 (assistant_enabled + assistant_seen, cross-device «видел»),
GET /api/assistant/context, POST seen/dismiss/ask, PATCH settings — гейт фичи 'pet'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 16:17:37 +03:00
parent 26c0ac0e58
commit 3f8009c59d
8 changed files with 599 additions and 0 deletions
@@ -0,0 +1,166 @@
'use strict';
/* assistantController — данные и состояние «Квантик-ассистента».
* GET /api/assistant/context — бандл для движка подсказок (1 запрос)
* POST /api/assistant/seen — отметить показ правила (++count)
* POST /api/assistant/dismiss — «не показывать это правило»
* PATCH /api/assistant/settings — вкл/выкл ассистента
* POST /api/assistant/ask — «Спроси Квантика» (поиск по FAQ; точка под LLM)
* Гейт фичи — общий с питомцем ('pet'), см. server.js. */
const db = require('../db/db');
/* Корпус справки для «Спроси Квантика» (поиск по ключевым словам в ask()).
* Это же место — контекст для будущей локальной модели (см. ask). Правьте свободно. */
const FAQ = [
{ id: 'clip-textbook', q: 'Как сохранить кусок учебника?',
a: 'На странице учебника нажми «Вырезать область» (внизу), выдели фрагмент — он сохранится картинкой в «Мои материалы».',
url: '/my-materials', keywords: ['учебник', 'вырезать', 'область', 'скриншот', 'фрагмент', 'сохранить', 'картинк', 'материал'] },
{ id: 'materials-folders', q: 'Как разложить материалы по папкам?',
a: 'В «Мои материалы» нажми «+ папка», затем у карточки выбери папку. Можно фильтровать по папкам и типам.',
url: '/my-materials', keywords: ['папк', 'материал', 'коллекци', 'разложить', 'сортиров', 'фильтр'] },
{ id: 'materials-annotate', q: 'Как рисовать поверх фото?',
a: 'Открой материал-картинку и нажми кнопку с карандашом-линейкой — откроется редактор. Сохранение обновит ту же карточку.',
url: '/my-materials', keywords: ['рисовать', 'аннотир', 'поверх', 'фото', 'карандаш', 'разметк', 'редактир'] },
{ id: 'flashcards', q: 'Как работают флешкарты?',
a: 'Создай колоду, добавь карточки (вопрос/ответ, можно картинку и формулы KaTeX). Система сама напомнит, что пора повторить.',
url: '/flashcards', keywords: ['флешкарт', 'карточк', 'колод', 'повтор', 'память', 'katex', 'формул'] },
{ id: 'exam-modes', q: 'Чем отличаются режимы экзамена?',
a: 'Экзамен — как на ЦТ/ЦЭ, на время. Тренировка — с разбором после каждого ответа. Случайный — быстрый набор вопросов.',
url: '/exam-prep', keywords: ['экзамен', 'режим', 'тренировк', 'случайн', 'цт', 'цэ', 'тест'] },
{ id: 'board-tools', q: 'Что умеет доска?',
a: 'Карандаш, маркер, лазер, фигуры, соединители, стикеры, текст, формулы KaTeX, таблицы, линейка и транспортир. «Выделение» двигает и поворачивает объекты.',
url: '/board', keywords: ['доск', 'инструмент', 'рисов', 'фигур', 'линейк', 'маркер', 'whiteboard'] },
{ id: 'pet', q: 'Зачем нужен питомец и XP?',
a: 'Квантик растёт от твоей активности: за тесты, уроки и карточки идут XP и монеты. Серия за ежедневные занятия поднимает настроение и даёт бонусы.',
url: '/pet', keywords: ['питомец', 'квантик', 'xp', 'опыт', 'монет', 'серия', 'streak', 'настроение', 'уровень'] },
{ id: 'homework', q: 'Где мои домашние задания?',
a: 'Все задания и дедлайны — в разделе «Домашние задания». Там же можно загрузить выполненную работу.',
url: '/homework', keywords: ['домашк', 'домашн', 'задани', 'дедлайн', 'сдать', 'загрузить', 'работ'] },
{ id: 'quick-lesson', q: 'Как создать один урок без курса?',
a: 'В «Теории» нажми «Быстрый урок» — урок создастся в скрытом личном контейнере, его не видно в общем каталоге.',
url: '/theory', keywords: ['урок', 'быстрый', 'без курса', 'создать', 'теори'] },
{ id: 'lab', q: 'Как открыть симуляции?',
a: 'В «Лаборатории» симуляции запускаются прямо в браузере — установка не нужна. Выбери предмет и опыт.',
url: '/lab', keywords: ['лаборатори', 'симуляци', 'опыт', 'эксперимент', 'физик', 'хими'] },
];
/* ── Источники проактивных подсказок (всё уже есть в БД) ──────────────── */
function dueCardsCount(uid) {
try {
return db.prepare(`
SELECT COUNT(*) AS n
FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id AND d.user_id = ?
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE r.id IS NULL OR r.due_at <= datetime('now')
`).get(uid, uid)?.n || 0;
} catch (e) { return 0; }
}
function pendingHomework(uid) {
// Несданные задания класса с дедлайном: нет завершённой попытки и нет отметки выполнения.
try {
const rows = db.prepare(`
SELECT a.id, a.title, a.deadline
FROM class_members cm
JOIN assignments a ON a.class_id = cm.class_id AND a.user_id IS NULL
WHERE cm.user_id = ? AND a.deadline IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM assignment_completion ac
WHERE ac.assignment_id = a.id AND ac.user_id = cm.user_id)
AND NOT EXISTS (SELECT 1 FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = a.id AND ax.user_id = cm.user_id)
ORDER BY a.deadline ASC
LIMIT 20
`).all(uid);
const now = Date.now();
const soonMs = 48 * 3600 * 1000;
let overdue = null, dueSoon = null;
for (const r of rows) {
const t = Date.parse(r.deadline);
if (isNaN(t)) continue;
if (t < now) { if (!overdue) overdue = { title: r.title, deadline: r.deadline }; }
else if (t - now <= soonMs) { if (!dueSoon) dueSoon = { title: r.title, deadline: r.deadline }; }
}
return { overdue, dueSoon };
} catch (e) { return { overdue: null, dueSoon: null }; }
}
/* ── GET /api/assistant/context ───────────────────────────────────────── */
function getContext(req, res) {
const uid = req.user.id;
const u = db.prepare('SELECT assistant_enabled FROM users WHERE id = ?').get(uid);
const seen = {};
try {
db.prepare('SELECT rule_id, count, dismissed, last_at FROM assistant_seen WHERE user_id = ?')
.all(uid)
.forEach(r => { seen[r.rule_id] = { count: r.count, dismissed: r.dismissed === 1, lastAt: r.last_at }; });
} catch (e) { /* table may be missing on a legacy instance */ }
res.json({
enabled: u ? u.assistant_enabled !== 0 : true,
seen,
dueCards: dueCardsCount(uid),
homework: pendingHomework(uid),
});
}
/* ── POST /api/assistant/seen { ruleId } ──────────────────────────────── */
function markSeen(req, res) {
const ruleId = String((req.body && req.body.ruleId) || '').slice(0, 60);
if (!ruleId) return res.status(400).json({ error: 'ruleId required' });
db.prepare(`
INSERT INTO assistant_seen (user_id, rule_id, count, last_at)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT(user_id, rule_id) DO UPDATE SET count = count + 1, last_at = datetime('now')
`).run(req.user.id, ruleId);
res.json({ ok: true });
}
/* ── POST /api/assistant/dismiss { ruleId } ───────────────────────────── */
function dismiss(req, res) {
const ruleId = String((req.body && req.body.ruleId) || '').slice(0, 60);
if (!ruleId) return res.status(400).json({ error: 'ruleId required' });
db.prepare(`
INSERT INTO assistant_seen (user_id, rule_id, count, dismissed, last_at)
VALUES (?, ?, 0, 1, datetime('now'))
ON CONFLICT(user_id, rule_id) DO UPDATE SET dismissed = 1, last_at = datetime('now')
`).run(req.user.id, ruleId);
res.json({ ok: true });
}
/* ── PATCH /api/assistant/settings { enabled } ────────────────────────── */
function setSettings(req, res) {
const enabled = (req.body && req.body.enabled) ? 1 : 0;
db.prepare('UPDATE users SET assistant_enabled = ? WHERE id = ?').run(enabled, req.user.id);
res.json({ ok: true, enabled: enabled === 1 });
}
/* ── POST /api/assistant/ask { q } ── «Спроси Квантика» ───────────────────
* Сейчас: поиск по FAQ (пересечение ключевых слов). Возвращает топ-совпадения.
*
* ТОЧКА РАСШИРЕНИЯ ПОД ЛОКАЛЬНУЮ МОДЕЛЬ:
* когда подключим локальную/облачную LLM — здесь вызываем askModel(q, hits),
* передав найденные FAQ как контекст, и возвращаем { source:'model', answer }.
* Сигнатуру ответа фронт уже понимает (поле source). */
function ask(req, res) {
const q = String((req.body && req.body.q) || '').trim().toLowerCase().slice(0, 300);
if (!q) return res.json({ source: 'faq', answers: [] });
const tokens = q.split(/[^a-zа-яё0-9]+/i).filter(t => t.length >= 3);
const scored = FAQ.map(item => {
let score = 0;
for (const t of tokens) {
if (item.keywords.some(k => k.indexOf(t) === 0 || t.indexOf(k) === 0)) score += 2;
if (item.q.toLowerCase().includes(t)) score += 1;
}
return { item, score };
}).filter(x => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 3);
// const answer = await askModel(q, scored.map(s => s.item)); // TODO: локальная модель
res.json({
source: 'faq',
answers: scored.map(s => ({ id: s.item.id, q: s.item.q, a: s.item.a, url: s.item.url || null })),
});
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask };