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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user