Files
Learn_System/backend/src/controllers/assistantController.js
T
Maxim Dolgolyov 479c621e2e feat(assistant): markdown+KaTeX, «Объясни это», репетитор на экзамене, флешкарты
- Ответы модели рендерятся как markdown + формулы KaTeX (ленивая загрузка),
  модель просим оформлять формулы в LaTeX $...$.
- «Объясни это»: ask принимает context; кнопки «Объяснить выделенное» (запоминаем
  выделение) и «Объяснить/Конспект параграфа» на учебнике.
- Репетитор на экзамене: кнопка «Спросить Квантика» на карточке задания →
  Assistant.ask с условием/ответом/решением как контекстом.
- Быстрые действия: «Флешкарты из параграфа» → POST /api/assistant/flashcards
  (модель → JSON, починка обрезанного) → колода через существующий API флешкарт.
- Экспорт Assistant.ask(q,context) / explainSelection().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:53:45 +03:00

320 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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: 'textbook-link', q: 'Как поделиться ссылкой на параграф?', a: 'Открой нужный параграф учебника и скопируй адрес из строки браузера — ссылка ведёт прямо на этот раздел.', url: '/textbooks', keywords: ['учебник','ссылк','параграф','поделит','раздел','deep'] },
{ id: 'textbooks-where', q: 'Где учебники?', a: 'Раздел «Учебники» в меню. Внутри — главы и параграфы с теорией, формулами и задачами.', url: '/textbooks', keywords: ['учебник','теори','глава','параграф','книг'] },
{ id: 'read-progress', q: 'Считается ли прогресс чтения?', a: 'Да — прочитанные параграфы отмечаются, прогресс по учебнику виден в самом учебнике и влияет на питомца.', url: '/textbooks', keywords: ['прогресс','чтени','прочит','отмет'] },
// ── Тесты / экзамен ──
{ id: 'exam-modes', q: 'Чем отличаются режимы экзамена?', a: 'Экзамен — как на ЦТ/ЦЭ, на время. Тренировка — с разбором после каждого ответа. Случайный — быстрый набор вопросов.', url: '/exam-prep/math9', keywords: ['экзамен','режим','тренировк','случайн','цт','цэ','тест'] },
{ id: 'exam-start', q: 'Как начать тест?', a: 'Зайди в «Подготовка к экзамену», выбери тему и режим. Можно быстро стартовать тест и с дашборда.', url: '/exam-prep/math9', keywords: ['тест','начать','старт','экзамен','реши'] },
{ id: 'exam-review', q: 'Как разобрать ошибки после теста?', a: 'После завершения теста доступен разбор: правильные ответы и решения по каждому заданию. В режиме тренировки разбор идёт сразу.', url: '/exam-prep/math9', keywords: ['ошибк','разбор','решени','ответ','проверк'] },
{ id: 'results-where', q: 'Где мои результаты?', a: 'Последние результаты — на дашборде, история и проценты по предметам — там же в виджетах.', url: '/dashboard', keywords: ['результат','оценк','процент','истори','статистик'] },
// ── Флешкарты ──
{ id: 'flashcards', q: 'Как работают флешкарты?', a: 'Создай колоду, добавь карточки (вопрос/ответ, можно картинку и формулы KaTeX). Система сама напомнит, что пора повторить.', url: '/flashcards', keywords: ['флешкарт','карточк','колод','повтор','память'] },
{ id: 'flashcards-katex', q: 'Как вставить формулу в карточку?', a: 'В редакторе карточки есть палитра KaTeX — выбирай символы или пиши LaTeX, превью покажет результат.', url: '/flashcards', keywords: ['формул','katex','latex','карточк','математ'] },
{ id: 'flashcards-img', q: 'Можно ли картинку на карточке?', a: 'Да — при создании карточки загрузи изображение, оно отрендерится на лицевой или оборотной стороне.', url: '/flashcards', keywords: ['картинк','изображен','карточк','фото'] },
{ id: 'flashcards-fab', q: 'Что за кнопка «Запомнить» в углу?', a: 'Это быстрый способ создать карточку из любого места платформы — плавающая кнопка справа внизу.', url: '/flashcards', keywords: ['запомнить','кнопк','быстр','карточк','fab'] },
// ── Мои материалы ──
{ id: 'materials-where', 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: 'materials-note', q: 'Как создать заметку?', a: 'В «Мои материалы» кнопка «Заметка» вверху. Заметку потом можно превратить во флешкарту.', url: '/my-materials', keywords: ['заметк','создать','текст','note'] },
{ id: 'materials-view', q: 'Почему картинка открывается в окне?', a: 'Клик по материалу-картинке открывает её в просмотрщике на странице. Там же «Скачать» и «В новой вкладке».', url: '/my-materials', keywords: ['картинк','открыт','просмотр','окно','лайтбокс'] },
// ── Доска / онлайн-урок ──
{ id: 'board-tools', q: 'Что умеет доска?', a: 'Карандаш, маркер, лазер, фигуры, соединители, стикеры, текст, формулы KaTeX, таблицы, линейка и транспортир. «Выделение» двигает и поворачивает объекты.', url: '/board', keywords: ['доск','инструмент','рисов','фигур','линейк','маркер','whiteboard'] },
{ id: 'board-save', q: 'Как сохранить доску себе?', a: 'На уроке или в архиве нажми «К себе» — страница доски сохранится в «Мои материалы». Можно сохранить и выделенную область.', url: '/my-materials', keywords: ['доск','сохранить','к себе','область','материал'] },
{ id: 'classroom-join', q: 'Как попасть на онлайн-урок?', a: 'Когда учитель начинает урок, в меню «Онлайн-урок» появится приглашение — нажми, чтобы войти.', url: '/classroom', keywords: ['онлайн','урок','classroom','подключ','войти','вызов'] },
{ id: 'classroom-materials', q: 'Сохранятся ли заметки с онлайн-урока?', a: 'Да — доску и заметки можно сохранить в «Мои материалы», они переживут окончание сессии.', url: '/my-materials', keywords: ['онлайн','урок','заметк','сохран','материал'] },
// ── Теория / уроки ──
{ id: 'theory', q: 'Что в разделе «Теория»?', a: 'Курсы и уроки с теорией и заданиями. Прогресс по урокам отслеживается.', url: '/theory', keywords: ['теори','курс','урок','обучен'] },
{ id: 'quick-lesson', q: 'Как создать один урок без курса?', a: 'В «Теории» нажми «Быстрый урок» — урок создастся в скрытом личном контейнере, его не видно в общем каталоге.', url: '/theory', keywords: ['урок','быстрый','без курса','создать','теори'] },
{ id: 'continue-lesson', q: 'Как продолжить с того же места?', a: 'На дашборде есть «Продолжить» с последним незаконченным уроком. Я тоже подскажу, когда есть что продолжить.', url: '/dashboard', keywords: ['продолж','урок','место','незаконч'] },
// ── Лаборатория / игры ──
{ id: 'lab', q: 'Как открыть симуляции?', a: 'В «Лаборатории» симуляции запускаются прямо в браузере — установка не нужна. Выбери предмет и опыт.', url: '/lab', keywords: ['лаборатори','симуляци','опыт','эксперимент','физик','хими'] },
{ id: 'optics', q: 'Есть ли конструктор оптики?', a: 'Да — в оптической скамье есть режим «Конструктор»: собирай системы из линз, зеркал и призм.', url: '/lab', keywords: ['оптик','линз','призм','конструктор','скамья'] },
{ id: 'games', q: 'Какие есть игры?', a: 'Кроссворд и «Виселица» по терминам предметов — закрепляют слова и понятия, дают XP.', url: '/crossword', keywords: ['игр','кроссворд','виселиц','термин','слов'] },
// ── Питомец / геймификация ──
{ id: 'pet', q: 'Зачем нужен питомец и XP?', a: 'Квантик растёт от твоей активности: за тесты, уроки и карточки идут XP и монеты. Серия за ежедневные занятия поднимает настроение и даёт бонусы.', url: '/pet', keywords: ['питомец','квантик','xp','опыт','монет','серия','streak','уровень'] },
{ id: 'streak', q: 'Что такое серия (streak)?', a: 'Серия — сколько дней подряд ты занимаешься. Чем длиннее, тем счастливее питомец и больше бонусов. Пропуск дня обнуляет серию.', url: '/pet', keywords: ['серия','streak','подряд','дни','бонус'] },
{ id: 'coins', q: 'Где потратить монеты?', a: 'В магазине на странице питомца — например, на фоны для Квантика.', url: '/pet', keywords: ['монет','магазин','купить','фон','награда'] },
{ id: 'achievements', q: 'Где достижения?', a: 'В профиле есть вкладка «Достижения» — ачивки за активность, серии и результаты.', url: '/profile', keywords: ['достижени','ачивк','награда','бейдж'] },
// ── Домашка / классы ──
{ id: 'homework', q: 'Где мои домашние задания?', a: 'Все задания и дедлайны — в разделе «Домашние задания». Там же можно загрузить выполненную работу.', url: '/homework', keywords: ['домашк','домашн','задани','дедлайн','сдать','загрузить'] },
{ id: 'join-class', q: 'Как вступить в класс?', a: 'Кнопка «Вступить в класс» в меню — введи код от учителя, и задания класса появятся у тебя.', url: '/dashboard', keywords: ['класс','вступить','код','присоедин'] },
// ── Профиль / настройки / поиск ──
{ id: 'search', q: 'Как искать по платформе?', a: 'Нажми «Поиск» в меню или Ctrl+K — найдёшь уроки, курсы, файлы и вопросы. Спроси меня — я тоже поищу.', url: null, keywords: ['поиск','найти','search','ctrl'] },
{ id: 'theme', q: 'Как включить тёмную тему?', a: 'В профиле → «Настройки» → «Внешний вид». Там же звуки и анимации.', url: '/profile', keywords: ['тема','тёмн','dark','внешний','настройк'] },
{ id: 'sounds', q: 'Как отключить звуки?', a: 'Профиль → «Настройки» → «Звуки системы»: общий выключатель, громкость и категории.', url: '/profile', keywords: ['звук','выключ','громкост','sound'] },
{ id: 'parent', q: 'Как дать доступ родителю?', a: 'В профиле есть «Доступ для родителей» — создай ссылку, по ней родитель видит твой прогресс.', url: '/profile', keywords: ['родител','доступ','ссылк','контрол'] },
{ id: 'bookmarks', q: 'Где мои закладки?', a: 'В профиле вкладка «Закладки» — сохранённые параграфы и страницы.', url: '/profile', keywords: ['закладк','сохран','избранн'] },
// ── Сам помощник ──
{ id: 'assistant-off', q: 'Как выключить помощника?', a: 'Профиль → «Настройки» → «Помощник Квантик». Можно выключить совсем или скрыть конкретные подсказки кнопкой «Не показывать».', url: '/profile', keywords: ['помощник','выключ','ассистент','подсказк','квантик','скрыть'] },
{ id: 'assistant-tour', q: 'Как пройти тур заново?', a: 'Нажми на меня и выбери «Тур по системе» — проведу по разделам ещё раз.', url: null, keywords: ['тур','онбординг','обзор','заново'] },
{ id: 'assistant-can', q: 'О чём тебя можно спросить?', a: 'Спрашивай «как сделать…»: вырезать учебник, создать карточки, начать тест, сохранить доску, разобрать ошибки, включить тёмную тему. Ещё я ищу уроки, курсы и файлы по платформе.', url: null, keywords: ['умеешь','помощь','спросить','что можешь','help','команд','о чём'] },
// ── Профиль / безопасность ──
{ id: 'password', q: 'Как сменить пароль?', a: 'Профиль → «Безопасность» → смена пароля.', url: '/profile', keywords: ['пароль','сменить','смена','безопасн','password'] },
{ id: 'avatar', q: 'Как поменять имя или аватар?', a: 'Профиль → «Аккаунт»: отображаемое имя и аватар.', url: '/profile', keywords: ['имя','аватар','профиль','фото','никнейм'] },
// ── Флешкарты подробнее ──
{ id: 'deck-create', q: 'Как создать колоду карточек?', a: 'В «Флешкартах» нажми создать колоду и задай название — затем добавляй карточки.', url: '/flashcards', keywords: ['колод','создать','набор','карточк'] },
{ id: 'deck-bulk', q: 'Как добавить много карточек сразу?', a: 'В колоде есть «Добавить список» — вставь пары «вопрос — ответ» построчно, можно с картинками.', url: '/flashcards', keywords: ['массов','список','импорт','много','сразу'] },
{ id: 'srs', q: 'Как работает интервальное повторение?', a: 'После ответа карточка получает срок следующего показа: лёгкие — реже, трудные — чаще. Так запоминается надолго.', url: '/flashcards', keywords: ['интервал','повторен','srs','память','алгоритм'] },
// ── Прогресс / поиск / экзамен9 ──
{ id: 'progress-subject', q: 'Как посмотреть прогресс по предмету?', a: 'На дашборде есть виджет «Прогресс по предметам» (средний процент) и история результатов.', url: '/dashboard', keywords: ['прогресс','предмет','процент','средн','статистик'] },
{ id: 'find-topic', q: 'Как быстро найти тему или урок?', a: 'Поиск (Ctrl+K) ищет уроки, курсы, файлы и вопросы по названию. Я тоже поищу, если спросишь.', url: null, keywords: ['найти','тема','урок','поиск','быстро'] },
{ id: 'exam9', q: 'Что такое «Подготовка к экзамену 9»?', a: 'Тренажёр для ЦТ/ЦЭ по математике 9: задания по темам, режимы экзамена и тренировки, разбор ошибок.', url: '/exam-prep/math9', keywords: ['экзамен 9','math9','цт','цэ','подготовка','9 класс'] },
// ── Питомец / без класса ──
{ id: 'feed-pet', q: 'Как покормить и погладить питомца?', a: 'На странице питомца есть кнопки: погладить (раз в минуту) и покормить (раз в 30 минут) — за это монеты и XP.', url: '/pet', keywords: ['покорм','погладит','питомец','еда','уход'] },
{ id: 'no-class', q: 'Можно заниматься без класса?', a: 'Да — учебники, тесты, карточки, лаборатория и питомец доступны и без класса. Класс нужен для заданий от учителя.', url: '/dashboard', 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 }; }
}
function activeLesson(uid, role) {
// Начатый, но не завершённый урок (как «продолжить чтение» на дашборде).
try {
const pub = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
const row = db.prepare(`
SELECT l.id AS lessonId, l.title AS lessonTitle, l.course_id AS courseId, c.title AS courseTitle
FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
JOIN courses c ON l.course_id = c.id
WHERE lp.user_id = ? AND lp.completed = 0 ${pub}
ORDER BY lp.updated_at DESC LIMIT 1
`).get(uid);
return row || null;
} catch (e) { return null; }
}
function weakSubject(uid) {
// Предмет с наименьшим средним по завершённым тестам (минимум 2 теста).
try {
const row = db.prepare(`
SELECT s.slug AS slug, 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
ORDER BY avg ASC LIMIT 1
`).get(uid);
return (row && row.avg != null && row.avg < 70) ? { slug: row.slug, name: row.name, avg: row.avg } : null;
} catch (e) { return 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),
activeLesson: activeLesson(uid, req.user.role),
weakSubject: weakSubject(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 });
}
/* Поиск по FAQ — пересечение ключевых слов; возвращает топ-N записей. */
function searchFaq(q, n) {
const tokens = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(t => t.length >= 3);
return 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, n || 3).map(x => x.item);
}
/* ── Подключение LLM (OpenAI-совместимый chat/completions) ────────────────
* Бесплатно подойдёт: Groq (дефолт, нужен бесплатный ключ console.groq.com),
* Google Gemini, OpenRouter (:free), либо локальный Ollama (без ключа).
* Настройка через ENV — менять провайдера без правки кода:
* ASSISTANT_LLM_URL (по умолч. Groq chat/completions)
* ASSISTANT_LLM_KEY (Bearer-ключ; для localhost/Ollama не нужен)
* ASSISTANT_LLM_MODEL (по умолч. llama-3.3-70b-versatile)
* Если ключ не задан и URL не локальный — тихо работаем как раньше (FAQ). */
const LLM_URL = process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions';
const LLM_KEY = process.env.ASSISTANT_LLM_KEY || '';
const LLM_MODEL = process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile';
const LLM_LOCAL = /\/\/(localhost|127\.0\.0\.1)/.test(LLM_URL);
const LLM_ON = !!(LLM_KEY || LLM_LOCAL);
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
async function callLLM(messages, maxTokens) {
if (typeof fetch !== 'function' || !LLM_ON) return null;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
const r = await fetch(LLM_URL, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, LLM_KEY ? { Authorization: `Bearer ${LLM_KEY}` } : {}),
body: JSON.stringify({ model: LLM_MODEL, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
signal: ctrl.signal,
});
if (!r.ok) return null;
const data = await r.json();
const text = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
return text ? String(text).trim() : null;
} catch (e) { return null; } finally { clearTimeout(timer); }
}
const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' +
'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' +
'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' +
'(если не знаешь — предложи поиск Ctrl+K). ' +
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.';
async function askModel(q, hits, context) {
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}`;
return callLLM([{ role: 'system', content: ASSISTANT_SYS }, { role: 'user', content: user }], 380);
}
/* ── POST /api/assistant/ask { q, context? } ── «Спроси Квантика» ─────────
* Грунтуем ответ топ-FAQ (+ опц. контекстом страницы/выделенного). Если LLM
* настроена — даём её ответ (source:'model'), иначе FAQ (source:'faq'). */
async function ask(req, res) {
const q = String((req.body && req.body.q) || '').trim().slice(0, 500);
if (!q || q.length < 2) return res.json({ source: 'faq', answer: null, answers: [] });
const context = String((req.body && req.body.context) || '').slice(0, 4000);
const hits = searchFaq(q, 3);
let answer = null;
if (LLM_ON) { try { answer = await askModel(q, hits, context); } catch (e) { answer = null; } }
res.json({
source: answer ? 'model' : 'faq',
answer: answer || null,
answers: hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null })),
});
}
/* ── POST /api/assistant/flashcards { text, title? } ─────────────────────
* Генерирует учебные карточки из текста (модель → JSON). Карточки фронт
* создаёт сам через существующий API флешкарт. */
async function flashcardsFromText(req, res) {
if (!LLM_ON) return res.status(503).json({ error: 'LLM не настроена' });
const text = String((req.body && req.body.text) || '').trim().slice(0, 6000);
const title = String((req.body && req.body.title) || 'Карточки').trim().slice(0, 80) || 'Карточки';
if (text.length < 20) return res.status(400).json({ error: 'Слишком мало текста' });
const sys = 'Ты составляешь учебные флешкарты по тексту. Верни СТРОГО JSON-массив из 5–6 объектов ' +
'вида {"front":"...","back":"..."} без markdown и пояснений. front — короткий вопрос, back — краткий ответ (1–2 предложения). ' +
'По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.';
const raw = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
let cards = [];
if (raw) {
let s = raw.replace(/```(?:json)?/gi, '').trim();
const a = s.indexOf('[');
if (a >= 0) {
const b = s.lastIndexOf(']');
if (b > a) s = s.slice(a, b + 1);
else { const last = s.lastIndexOf('}'); s = last > a ? s.slice(a, last + 1) + ']' : ''; } // починка обрезанного JSON
}
try {
const arr = JSON.parse(s);
if (Array.isArray(arr)) {
cards = arr.filter(c => c && c.front && c.back)
.slice(0, 8)
.map(c => ({ front: String(c.front).slice(0, 500), back: String(c.back).slice(0, 1000) }));
}
} catch (e) { /* модель вернула не-JSON */ }
}
if (!cards.length) return res.status(502).json({ error: 'Не удалось сгенерировать карточки' });
res.json({ title, cards });
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText };