feat(assistant): markdown+KaTeX, «Объясни это», репетитор на экзамене, флешкарты

- Ответы модели рендерятся как 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>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 17:53:45 +03:00
parent 638b684f77
commit 479c621e2e
5 changed files with 231 additions and 58 deletions
+62 -20
View File
@@ -230,25 +230,18 @@ 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);
async function askModel(q, hits) {
if (typeof fetch !== 'function') return null;
const ctx = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
const sys = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' +
'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' +
'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' +
'(если не знаешь — предложи поиск Ctrl+K). ' +
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
'Не используй эмодзи.';
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(), 12000);
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: 320,
messages: [{ role: 'system', content: sys }, { role: 'user', content: `Справка:\n${ctx}\n\nВопрос: ${q}` }],
}),
body: JSON.stringify({ model: LLM_MODEL, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
signal: ctrl.signal,
});
if (!r.ok) return null;
@@ -258,15 +251,30 @@ async function askModel(q, hits) {
} catch (e) { return null; } finally { clearTimeout(timer); }
}
/* ── POST /api/assistant/ask { q } ── «Спроси Квантика» ───────────────────
* Грунтуем ответ топ-FAQ. Если LLM настроена — даём её ответ (source:'model'),
* иначе отдаём найденные FAQ (source:'faq'). Поиск по платформе фронт делает сам. */
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, 300);
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_KEY || LLM_LOCAL) { try { answer = await askModel(q, hits); } catch (e) { 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,
@@ -274,4 +282,38 @@ async function ask(req, res) {
});
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask };
/* ── 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 };