feat(assistant): источники в ответах, режим-наставник, оценки, утренний бриф
- Источники: RAG возвращает sources (slug/§/ref), под ответом ссылка «по учебнику X, §N» на параграф (миграция 064: section_ref в textbook_chunks; headless-индексатор его заполняет). Статический индексатор теперь не затирает headless-данные. - Режим-наставник: переключатель Ответ/Подсказка/Проверить решение в «Спроси» (mode в ask + промпт); на карточке экзамена кнопка «Подсказка» (mode hint). - Оценка ответов: лайк/дизлайк под ответом (assistant_feedback) + сводка в админке. - Утренний бриф на дашборде: «занимался N из 5 дн + план на сегодня». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ async function run() {
|
||||
if (!exe) { console.error('Браузер не найден (Chrome/Edge)'); process.exit(1); }
|
||||
const books = db.prepare('SELECT slug, title FROM textbooks WHERE is_active = 1 ORDER BY slug').all();
|
||||
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?');
|
||||
const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text) VALUES (?, ?, ?, ?)');
|
||||
const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text, section_ref) VALUES (?, ?, ?, ?, ?)');
|
||||
|
||||
const token = authToken();
|
||||
if (!token) { console.error('Не удалось выпустить токен (нет пользователя или JWT_SECRET)'); process.exit(1); }
|
||||
@@ -59,7 +59,7 @@ async function run() {
|
||||
await page.evaluate(id => { const c = document.querySelector('.psel-card[data-id="' + id + '"]'); if (c) c.click(); }, s.id);
|
||||
await sleep(550);
|
||||
const text = await page.evaluate(() => { const a = document.querySelector('.sec.active'); return a ? a.innerText.replace(/\s+/g, ' ').trim() : ''; });
|
||||
if (text && text.length >= 80) chunks.push({ section: s.name.slice(0, 160), text: text.slice(0, 2000) });
|
||||
if (text && text.length >= 80) chunks.push({ section: s.name.slice(0, 160), text: text.slice(0, 2000), ref: s.id });
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
@@ -70,7 +70,7 @@ async function run() {
|
||||
|
||||
if (chunks.length) {
|
||||
del.run(b.slug);
|
||||
for (const c of chunks) ins.run(b.slug, b.title || b.slug, c.section, c.text);
|
||||
for (const c of chunks) ins.run(b.slug, b.title || b.slug, c.section, c.text, c.ref || null);
|
||||
okBooks++; totalChunks += chunks.length;
|
||||
console.log(` ${b.slug}: ${chunks.length}`);
|
||||
} else {
|
||||
|
||||
@@ -48,17 +48,19 @@ function reindex() {
|
||||
let books;
|
||||
try { books = db.prepare('SELECT slug, title, html_path FROM textbooks WHERE is_active = 1').all(); }
|
||||
catch (e) { return { error: 'textbooks table missing', chunks: 0 }; }
|
||||
const delAll = db.prepare('DELETE FROM textbook_chunks');
|
||||
// Замещаем чанки только тех книг, что реально распарсились — не трогаем
|
||||
// данные, наполненные headless-индексатором (JS-рендеримые учебники).
|
||||
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?');
|
||||
const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text) VALUES (?, ?, ?, ?)');
|
||||
let total = 0, files = 0;
|
||||
delAll.run();
|
||||
for (const b of books) {
|
||||
const fp = path.join(TEXTBOOKS_DIR, b.html_path || '');
|
||||
let html;
|
||||
try { html = fs.readFileSync(fp, 'utf8'); } catch (e) { continue; }
|
||||
files++;
|
||||
const chunks = chunksFromHtml(html);
|
||||
if (!chunks.length) continue;
|
||||
del.run(b.slug);
|
||||
for (const c of chunks) { ins.run(b.slug, b.title || b.slug, c.section || '', c.text); total++; }
|
||||
}
|
||||
return { books: books.length, files, chunks: total };
|
||||
|
||||
@@ -900,9 +900,15 @@ function getAssistant(_req, res) {
|
||||
const s = db.prepare("SELECT COALESCE(SUM(model_calls),0) model_calls, COALESCE(SUM(cache_hits),0) cache_hits, COALESCE(SUM(faq),0) faq FROM assistant_usage WHERE day > date('now','-30 days')").get();
|
||||
if (s) usage30 = s;
|
||||
} catch (e) {}
|
||||
let feedback = { up: 0, down: 0, recent: [] };
|
||||
try {
|
||||
const f = db.prepare("SELECT COALESCE(SUM(CASE WHEN rating=1 THEN 1 ELSE 0 END),0) up, COALESCE(SUM(CASE WHEN rating=-1 THEN 1 ELSE 0 END),0) down FROM assistant_feedback WHERE created_at > date('now','-30 days')").get();
|
||||
if (f) { feedback.up = f.up; feedback.down = f.down; }
|
||||
feedback.recent = db.prepare("SELECT q, created_at FROM assistant_feedback WHERE rating=-1 AND q IS NOT NULL AND q <> '' ORDER BY id DESC LIMIT 5").all();
|
||||
} catch (e) {}
|
||||
res.json({
|
||||
url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local),
|
||||
rag: _aset('assistant_rag') !== '0', chunks, usage, usage30, presets: ASSISTANT_PRESETS,
|
||||
rag: _aset('assistant_rag') !== '0', chunks, usage, usage30, feedback, presets: ASSISTANT_PRESETS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -237,21 +237,27 @@ function llmConfig() {
|
||||
return { url, key, model, local, on: !!(key || local) };
|
||||
}
|
||||
|
||||
/* RAG: релевантные куски учебников (textbook_chunks) под вопрос. */
|
||||
/* RAG: релевантные куски учебников (textbook_chunks) под вопрос.
|
||||
* Возвращает { text, sources:[{slug,title,section,ref}] } для цитирования. */
|
||||
function ragContext(q) {
|
||||
const empty = { text: '', sources: [] };
|
||||
try {
|
||||
if (_setting('assistant_rag') === '0') return '';
|
||||
if (_setting('assistant_rag') === '0') return empty;
|
||||
const words = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).slice(0, 8);
|
||||
if (!words.length) return '';
|
||||
if (!words.length) return empty;
|
||||
const args = words.map(w => '%' + w + '%');
|
||||
const rows = db.prepare(`SELECT textbook_title, section_title, text FROM textbook_chunks WHERE ${words.map(() => 'text LIKE ?').join(' OR ')} LIMIT 60`).all(...args);
|
||||
if (!rows.length) return '';
|
||||
const rows = db.prepare(`SELECT slug, textbook_title, section_title, section_ref, text FROM textbook_chunks WHERE ${words.map(() => 'text LIKE ?').join(' OR ')} LIMIT 60`).all(...args);
|
||||
if (!rows.length) return empty;
|
||||
rows.forEach(r => { const t = r.text.toLowerCase(); r._s = words.reduce((s, w) => s + (t.indexOf(w) >= 0 ? 1 : 0), 0); });
|
||||
rows.sort((a, b) => b._s - a._s);
|
||||
const need = Math.min(2, words.length);
|
||||
return rows.filter(r => r._s >= need).slice(0, 2)
|
||||
.map(r => `«${r.textbook_title}»${r.section_title ? ' — ' + r.section_title : ''}:\n${r.text.slice(0, 1200)}`).join('\n\n');
|
||||
} catch (e) { return ''; }
|
||||
const top = rows.filter(r => r._s >= need).slice(0, 2);
|
||||
if (!top.length) return empty;
|
||||
return {
|
||||
text: top.map(r => `«${r.textbook_title}»${r.section_title ? ' — ' + r.section_title : ''}:\n${r.text.slice(0, 1200)}`).join('\n\n'),
|
||||
sources: top.map(r => ({ slug: r.slug, title: r.textbook_title, section: r.section_title || '', ref: r.section_ref || null })),
|
||||
};
|
||||
} catch (e) { return empty; }
|
||||
}
|
||||
|
||||
/* Суточный счётчик использования (для админки). */
|
||||
@@ -315,7 +321,7 @@ const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помо
|
||||
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
|
||||
'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.';
|
||||
|
||||
async function askModel(q, hits, context, history, role) {
|
||||
async function askModel(q, hits, context, history, role, mode) {
|
||||
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}`;
|
||||
@@ -324,6 +330,11 @@ async function askModel(q, hits, context, history, role) {
|
||||
sys += ' Пользователь — учитель: помогай и с преподаванием — составить задание или вопросы, план урока, ' +
|
||||
'объяснить, как пользоваться учительскими инструментами (классы, журнал, аналитика, онлайн-урок, банк вопросов).';
|
||||
}
|
||||
if (mode === 'hint') {
|
||||
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.';
|
||||
} else if (mode === 'check') {
|
||||
sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.';
|
||||
}
|
||||
const msgs = [{ role: 'system', content: 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 });
|
||||
@@ -337,36 +348,47 @@ 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 pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
|
||||
const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer';
|
||||
let history = (req.body && req.body.history);
|
||||
history = Array.isArray(history) ? history.slice(-6) : [];
|
||||
const hits = searchFaq(q, 3);
|
||||
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
|
||||
|
||||
if (!llmConfig().on) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson }); }
|
||||
if (!llmConfig().on) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); }
|
||||
|
||||
// Кэш — только для «чистых» вопросов (без контекста страницы и без истории диалога)
|
||||
const cacheable = !pageCtx && !history.length;
|
||||
const rag = ragContext(q);
|
||||
|
||||
// Кэш — только обычный режим без контекста страницы и без истории диалога
|
||||
const cacheable = mode === 'answer' && !pageCtx && !history.length;
|
||||
const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
if (cacheable) {
|
||||
try {
|
||||
const c = db.prepare("SELECT answer FROM assistant_cache WHERE qhash = ? AND created_at > datetime('now','-7 days')").get(qhash);
|
||||
if (c) { bumpUsage('cache_hits'); return res.json({ source: 'model', answer: c.answer, answers: faqJson, cached: true }); }
|
||||
if (c) { bumpUsage('cache_hits'); return res.json({ source: 'model', answer: c.answer, answers: faqJson, sources: rag.sources, cached: true }); }
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const rag = ragContext(q);
|
||||
let context = pageCtx;
|
||||
if (rag) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag;
|
||||
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
|
||||
|
||||
let answer = null;
|
||||
try { answer = await askModel(q, hits, context, history, req.user && req.user.role); } catch (e) { answer = null; }
|
||||
try { answer = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { answer = null; }
|
||||
|
||||
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) {} }
|
||||
} else { bumpUsage('faq'); }
|
||||
|
||||
res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson });
|
||||
res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson, sources: answer ? rag.sources : [] });
|
||||
}
|
||||
|
||||
/* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */
|
||||
function feedback(req, res) {
|
||||
const rating = (req.body && req.body.rating) === 1 ? 1 : ((req.body && req.body.rating) === -1 ? -1 : 0);
|
||||
if (!rating) return res.status(400).json({ error: 'rating must be 1 or -1' });
|
||||
const q = String((req.body && req.body.q) || '').slice(0, 300);
|
||||
try { db.prepare('INSERT INTO assistant_feedback (user_id, rating, q) VALUES (?, ?, ?)').run(req.user.id, rating, q || null); } catch (e) {}
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── POST /api/assistant/flashcards { text, title? } ─────────────────────
|
||||
@@ -403,4 +425,4 @@ async function flashcardsFromText(req, res) {
|
||||
res.json({ title, cards });
|
||||
}
|
||||
|
||||
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, llmConfig, pingLLM };
|
||||
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, llmConfig, pingLLM };
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 064: Ассистент — источники в ответах + оценки качества
|
||||
--
|
||||
-- section_ref — id параграфа (sec-<id>) у куска учебника, чтобы под RAG-ответом
|
||||
-- давать ссылку «по учебнику X, §N» прямо на параграф (/textbook/<slug>#sec-<ref>).
|
||||
-- assistant_feedback — лайк/дизлайк на ответы (для оценки качества в админке).
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE textbook_chunks ADD COLUMN section_ref TEXT;
|
||||
|
||||
CREATE TABLE assistant_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
rating INTEGER NOT NULL, -- 1 = лайк, -1 = дизлайк
|
||||
q TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_assistant_feedback_created ON assistant_feedback(created_at);
|
||||
@@ -13,5 +13,6 @@ router.post('/dismiss', ctrl.dismiss);
|
||||
router.patch('/settings', ctrl.setSettings);
|
||||
router.post('/ask', ctrl.ask);
|
||||
router.post('/flashcards', ctrl.flashcardsFromText);
|
||||
router.post('/feedback', ctrl.feedback);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user