'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). */ /* Конфиг берём из app_settings (правится из админки без рестарта), с откатом * на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */ function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } } function llmConfig() { const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions'; const key = _setting('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY || ''; const model = _setting('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile'; const local = /\/\/(localhost|127\.0\.0\.1)/.test(url); return { url, key, model, local, on: !!(key || local) }; } /* Низкоуровневый вызов OpenAI-совместимого chat/completions. */ async function callLLM(messages, maxTokens, override) { const cfg = override || llmConfig(); if (typeof fetch !== 'function' || !cfg.on) return null; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000); try { const r = await fetch(cfg.url, { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}), body: JSON.stringify({ model: cfg.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); } } /* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */ async function pingLLM(override) { const cfg = override || llmConfig(); if (!cfg.url) return { ok: false, error: 'URL не задан' }; if (!cfg.key && !/\/\/(localhost|127\.0\.0\.1)/.test(cfg.url)) return { ok: false, error: 'Ключ не задан' }; if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' }; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000); try { const r = await fetch(cfg.url, { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}), body: JSON.stringify({ model: cfg.model, max_tokens: 16, messages: [{ role: 'user', content: 'Ответь одним словом: привет' }] }), signal: ctrl.signal, }); const txt = await r.text(); if (!r.ok) { let msg = txt.slice(0, 300); try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 300); } catch (e) {} return { ok: false, status: r.status, error: msg }; } let sample = ''; try { const j = JSON.parse(txt); sample = String((j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || '').slice(0, 120); } catch (e) {} return { ok: true, status: r.status, sample, model: cfg.model }; } catch (e) { return { ok: false, error: e.name === 'AbortError' ? 'Таймаут (15с)' : (e.message || 'Ошибка сети') }; } finally { clearTimeout(timer); } } const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' + 'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' + 'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' + '(если не знаешь — предложи поиск Ctrl+K). ' + 'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' + 'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.'; async function askModel(q, hits, context, history) { 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}`; const msgs = [{ role: 'system', content: ASSISTANT_SYS }]; (history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); }); msgs.push({ role: 'user', content: user }); return callLLM(msgs, 420); } /* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─ * Грунтуем ответ топ-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); let history = (req.body && req.body.history); history = Array.isArray(history) ? history.slice(-6) : []; const hits = searchFaq(q, 3); let answer = null; if (llmConfig().on) { try { answer = await askModel(q, hits, context, history); } 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 (!llmConfig().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, llmConfig, pingLLM };