diff --git a/backend/.env.example b/backend/.env.example index 6f1d71d..99bb54f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,3 +13,14 @@ CLIENT_ORIGIN=http://localhost:5500 # TURN_URL=turn:turn.example.com:3478 # TURN_USER=username # TURN_PASS=password + +# Помощник «Квантик» — LLM для «Спроси» (необязательно). +# Бесплатно и подходит: Groq — заведи ключ на console.groq.com → API Keys, +# вставь в ASSISTANT_LLM_KEY и перезапусти сервер. Без ключа «Спроси» работает +# на FAQ + поиске по платформе (как сейчас). +# ASSISTANT_LLM_URL=https://api.groq.com/openai/v1/chat/completions +ASSISTANT_LLM_KEY= +# ASSISTANT_LLM_MODEL=llama-3.3-70b-versatile +# Локально без ключа (Ollama): `ollama serve` + `ollama pull qwen2.5:3b`, затем +# ASSISTANT_LLM_URL=http://localhost:11434/v1/chat/completions +# ASSISTANT_LLM_MODEL=qwen2.5:3b diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index e13a512..90fdbb8 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -204,31 +204,70 @@ function setSettings(req, res) { 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 => { +/* Поиск по FAQ — пересечение ключевых слов; возвращает топ-N записей. */ +function searchFaq(q, n) { + const tokens = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(t => t.length >= 3); + return 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); + }).filter(x => x.score > 0).sort((a, b) => b.score - a.score).slice(0, n || 3).map(x => x.item); +} - // const answer = await askModel(q, scored.map(s => s.item)); // TODO: локальная модель +/* ── Подключение LLM (OpenAI-совместимый chat/completions) ──────────────── + * Бесплатно подойдёт: Groq (дефолт, нужен бесплатный ключ console.groq.com), + * Google Gemini, OpenRouter (:free), либо локальный Ollama (без ключа). + * Настройка через ENV — менять провайдера без правки кода: + * ASSISTANT_LLM_URL (по умолч. Groq chat/completions) + * ASSISTANT_LLM_KEY (Bearer-ключ; для localhost/Ollama не нужен) + * ASSISTANT_LLM_MODEL (по умолч. llama-3.3-70b-versatile) + * Если ключ не задан и URL не локальный — тихо работаем как раньше (FAQ). */ +const LLM_URL = process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions'; +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. ' + + 'Отвечай по-русски, кратко (2–4 предложения), на «ты». Помогай пользоваться платформой, ' + + 'опираясь ТОЛЬКО на справку ниже. Если в справке нет ответа — честно скажи и предложи поиск (Ctrl+K). Не выдумывай.'; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 12000); + 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}` }], + }), + signal: ctrl.signal, + }); + if (!r.ok) return null; + const data = await r.json(); + const text = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; + return text ? String(text).trim() : null; + } catch (e) { return null; } finally { clearTimeout(timer); } +} + +/* ── POST /api/assistant/ask { q } ── «Спроси Квантика» ─────────────────── + * Грунтуем ответ топ-FAQ. Если LLM настроена — даём её ответ (source:'model'), + * иначе отдаём найденные FAQ (source:'faq'). Поиск по платформе фронт делает сам. */ +async function ask(req, res) { + const q = String((req.body && req.body.q) || '').trim().slice(0, 300); + if (!q || q.length < 2) return res.json({ source: 'faq', answer: null, answers: [] }); + const hits = searchFaq(q, 3); + let answer = null; + if (LLM_KEY || LLM_LOCAL) { try { answer = await askModel(q, hits); } catch (e) { answer = null; } } 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 })), + source: answer ? 'model' : 'faq', + answer: answer || null, + answers: hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null })), }); } diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index 03b5f0b..63930a0 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -389,9 +389,15 @@ LS.assistantAsk(q).catch(function () { return { answers: [] }; }), (LS.globalSearch ? LS.globalSearch(q, 'all', 4) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }), ]).then(function (res) { + var modelAnswer = res[0] && res[0].answer; var ans = (res[0] && res[0].answers) || []; var found = (res[1] && res[1].results) || []; var html = ''; + if (modelAnswer) { + html += '