479c621e2e
- Ответы модели рендерятся как 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>
320 lines
33 KiB
JavaScript
320 lines
33 KiB
JavaScript
'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 };
|