@@ -152,6 +152,93 @@ function weakSubject(uid) {
} catch ( e ) { return null ; }
}
/* ── Долгая память об ученике ─────────────────────────────────────────── */
// Производный профиль (без LLM) — из уже накопленных сигналов.
function _studentProfile ( uid ) {
const out = { weakSubjects : [ ] , weakTopics : [ ] , exam : null , streak : 0 } ;
try {
out . weakSubjects = db . prepare ( `
SELECT 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 AND avg < 70 ORDER BY avg ASC LIMIT 3
` ) . all ( uid ) . map ( r => ( { name : r . name , avg : r . avg } ) ) ;
} catch ( e ) { }
try {
out . weakTopics = db . prepare ( `
SELECT et.topic AS topic, COUNT(*) AS attempts, SUM(ea.is_correct) AS correct
FROM exam_attempts ea JOIN exam_tasks et ON et.id = ea.exam_task_id
WHERE ea.user_id = ? AND et.topic IS NOT NULL AND et.topic <> ''
GROUP BY et.topic HAVING attempts >= 3 AND (correct * 1.0 / attempts) < 0.6
ORDER BY (correct * 1.0 / attempts) ASC LIMIT 3
` ) . all ( uid ) . map ( r => ( { topic : r . topic , rate : Math . round ( r . correct * 100 / r . attempts ) } ) ) ;
} catch ( e ) { }
try {
const p = db . prepare ( 'SELECT exam_key, exam_date FROM exam_user_plan WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1' ) . get ( uid ) ;
if ( p ) out . exam = { key : p . exam _key , date : p . exam _date } ;
} catch ( e ) { }
try { out . streak = db . prepare ( 'SELECT streak_current FROM users WHERE id = ?' ) . get ( uid ) ? . streak _current || 0 ; } catch ( e ) { }
return out ;
}
// Текстовый блок памяти для подмешивания в промпт (профиль + заметки).
function _memoryBlock ( uid ) {
if ( _setting ( 'assistant_memory' ) === '0' ) return '' ;
const parts = [ ] , p = _studentProfile ( uid ) ;
if ( p . exam ) parts . push ( ` готовится к экзамену ( ${ p . exam . key } ${ p . exam . date ? ', дата ' + p . exam . date : '' } ) ` ) ;
if ( p . weakSubjects . length ) parts . push ( 'слабые предметы: ' + p . weakSubjects . map ( s => ` ${ s . name } ( ${ s . avg } %) ` ) . join ( ', ' ) ) ;
if ( p . weakTopics . length ) parts . push ( 'трудные темы: ' + p . weakTopics . map ( t => ` ${ t . topic } ( ${ t . rate } %) ` ) . join ( ', ' ) ) ;
if ( p . streak >= 3 ) parts . push ( ` серия занятий ${ p . streak } дн. ` ) ;
try {
const notes = db . prepare ( 'SELECT text FROM assistant_memory WHERE user_id = ? ORDER BY weight DESC, updated_at DESC LIMIT 8' ) . all ( uid ) . map ( r => r . text ) ;
if ( notes . length ) parts . push ( 'заметки: ' + notes . join ( '; ' ) ) ;
} catch ( e ) { }
return parts . join ( '; ' ) ;
}
// Upsert заметки с дедупликацией и лимитом.
function _memUpsert ( uid , kind , text , weight , source ) {
try {
const key = text . toLowerCase ( ) . slice ( 0 , 24 ) ;
const ex = db . prepare ( 'SELECT id FROM assistant_memory WHERE user_id = ? AND lower(text) LIKE ?' ) . get ( uid , '%' + key + '%' ) ;
if ( ex ) { db . prepare ( "UPDATE assistant_memory SET weight = weight + 0.5, updated_at = datetime('now') WHERE id = ?" ) . run ( ex . id ) ; return ; }
db . prepare ( "INSERT INTO assistant_memory (user_id, kind, text, weight, source) VALUES (?, ?, ?, ?, ?)" ) . run ( uid , kind , text . slice ( 0 , 200 ) , weight , source ) ;
const cnt = db . prepare ( 'SELECT COUNT(*) AS n FROM assistant_memory WHERE user_id = ?' ) . get ( uid ) . n ;
if ( cnt > 15 ) db . prepare ( 'DELETE FROM assistant_memory WHERE id IN (SELECT id FROM assistant_memory WHERE user_id = ? ORDER BY weight ASC, updated_at ASC LIMIT ?)' ) . run ( uid , cnt - 15 ) ;
} catch ( e ) { }
}
// Экстрактор: 1 устойчивый факт об ученике из реплики+ответа (фоновый, дросселированный).
async function _extractMemory ( uid , q , answer ) {
try {
const sys = 'Ты ведёшь короткие заметки о трудностях, предпочтениях и целях ученика для персонализации обучения. ' +
'По вопросу ученика и ответу выдели ОДИН устойчивый факт об ученике (что даётся трудно / что путает / предпочтение / цель). ' +
'Ответь короткой фразой по-русски (до 12 слов), без кавычек. Если устойчивого факта нет — ответь ровно NONE.' ;
const r = await callLLMFailover ( [ { role : 'system' , content : sys } , { role : 'user' , content : ` Вопрос: ${ q } \n Ответ: ${ String ( answer ) . slice ( 0 , 500 ) } ` } ] , 40 ) ;
const note = r && r . text && r . text . trim ( ) . replace ( /^["'«»]+|["'«»]+$/g , '' ) ;
if ( ! note || /^none\b/i . test ( note ) || note . length < 5 || note . length > 120 ) return ;
_memUpsert ( uid , 'note' , note , 1 , 'extractor' ) ;
} catch ( e ) { }
}
/* ── GET /api/assistant/memory — что Квантик знает об ученике ──────────── */
function getMemory ( req , res ) {
const uid = req . user . id ;
let notes = [ ] ;
try { notes = db . prepare ( 'SELECT id, kind, text FROM assistant_memory WHERE user_id = ? ORDER BY weight DESC, updated_at DESC' ) . all ( uid ) ; } catch ( e ) { }
res . json ( { enabled : _setting ( 'assistant_memory' ) !== '0' , profile : _studentProfile ( uid ) , notes } ) ;
}
/* ── DELETE /api/assistant/memory[/:id] — забыть всё / одну заметку ────── */
function clearMemory ( req , res ) {
const uid = req . user . id , id = req . params . id ? Number ( req . params . id ) : null ;
try {
if ( id ) db . prepare ( 'DELETE FROM assistant_memory WHERE id = ? AND user_id = ?' ) . run ( id , uid ) ;
else db . prepare ( 'DELETE FROM assistant_memory WHERE user_id = ?' ) . run ( uid ) ;
} catch ( e ) { }
res . json ( { ok : true } ) ;
}
/* ── GET /api/assistant/context ───────────────────────────────────────── */
function getContext ( req , res ) {
const uid = req . user . id ;
@@ -388,7 +475,7 @@ const META_RE = new RegExp('(' + _SELF + '[\\sа -яёa-z0-9,?!.-]{0,25}' + _TERM
'|на\\s+ч[её]м\\s+ты\\s+(?:работа|сдела|постро|основ)|кто\\s+тебя\\s+(?:сделал|создал|обуч|разработ|написал)|систем[а-яё]*\\s+промпт|what\\s+model\\s+are\\s+you|which\\s+(?:ai\\s+)?model|your\\s+system\\s+prompt)' , 'i' ) ;
const META _ANSWER = 'Я — Квантик, помощник LearnSpace. Помогаю с учёбой и навигацией по платформе. Давай вернёмся к делу — что объяснить или подсказать?' ;
async function askModel ( q , hits , context , history , role , mode ) {
async function askModel ( q , hits , context , history , role , mode , mem ) {
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 } ` ;
@@ -397,6 +484,7 @@ async function askModel(q, hits, context, history, role, mode) {
sys += ' Пользователь — учитель: помогай и с преподаванием — составить задание или вопросы, план урока, ' +
'объяснить, как пользоваться учительскими инструментами (классы, журнал, аналитика, онлайн-урок, банк вопросов).' ;
}
if ( mem ) sys += ' Что известно об ученике (учитывай, чтобы персонализировать объяснение; НЕ зачитывай это вслух): ' + mem + '.' ;
if ( mode === 'hint' ) {
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.' ;
} else if ( mode === 'check' ) {
@@ -427,9 +515,10 @@ async function ask(req, res) {
if ( ! providersOrdered ( ) . length ) { bumpUsage ( 'faq' ) ; return res . json ( { source : 'faq' , answer : null , answers : faqJson , sources : [ ] } ) ; }
const rag = ragContext ( q ) ;
const mem = _memoryBlock ( req . user . id ) ;
// Кэш — только обычный режим без контекста страницы и без истории диалога
const cacheable = mode === 'answer' && ! pageCtx && ! history . length ;
// Кэш — только обычный режим без контекста/истории И без персональной памяти (ответ персонализирован)
const cacheable = mode === 'answer' && ! pageCtx && ! history . length && ! mem ;
const qhash = q . toLowerCase ( ) . replace ( /\s+/g , ' ' ) . trim ( ) ;
if ( cacheable ) {
try {
@@ -442,12 +531,14 @@ async function ask(req, res) {
if ( rag . text ) context = ( context ? context + '\n\n' : '' ) + 'Из учебников:\n' + rag . text ;
let r = { text : null , error : 'network' } ;
try { r = await askModel ( q , hits , context , history , req . user && req . user . role , mode ) ; } catch ( e ) { r = { text : null , error : 'network' } ; }
try { r = await askModel ( q , hits , context , history , req . user && req . user . role , mode , mem ) ; } catch ( e ) { r = { text : null , error : 'network' } ; }
const answer = r && r . text ;
if ( answer ) {
bumpUsage ( 'model_calls' ) ;
if ( cacheable ) { try { db . prepare ( "INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))" ) . run ( qhash , answer ) ; } catch ( e ) { } }
// Фоновая экстракция заметки об ученике — после содержательного диалога/проверки
if ( _setting ( 'assistant_memory' ) !== '0' && ( mode === 'check' || history . length >= 4 ) ) _extractMemory ( req . user . id , q , answer ) ;
return res . json ( { source : 'model' , answer , answers : faqJson , sources : rag . sources } ) ;
}
bumpUsage ( 'faq' ) ;
@@ -504,4 +595,4 @@ async function flashcardsFromText(req, res) {
res . json ( { title , cards } ) ;
}
module . exports = { getContext , markSeen , dismiss , setSettings , ask , flashcardsFromText , feedback , llmConfig , pingLLM , clearFailover : _clearFailover } ;
module . exports = { getContext , markSeen , dismiss , setSettings , ask , flashcardsFromText , feedback , getMemory , clearMemory , llmConfig , pingLLM , clearFailover : _clearFailover } ;