@@ -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 } ;