feat(assistant): «Спроси» через бесплатную LLM (Groq по умолчанию), грунтовка по FAQ

ask() умеет вызывать OpenAI-совместимую модель: топ-FAQ как контекст, краткий
ответ на русском (source:'model'), таймаут 12с, при ошибке/без ключа — мягкий
откат на FAQ. Конфиг через ENV (ASSISTANT_LLM_URL/KEY/MODEL): дефолт — Groq
(бесплатный ключ), поддержан и локальный Ollama без ключа. Фронт показывает
ответ модели сверху, FAQ и поиск по платформе — ниже. .env.example дополнен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 17:22:32 +03:00
parent e1cde834d0
commit 9dbc0443af
3 changed files with 73 additions and 17 deletions
+11
View File
@@ -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
+56 -17
View File
@@ -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 })),
});
}
+6
View File
@@ -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 += '<div class="asst-ans"><div class="asst-ans-q">Квантик</div>' +
'<div style="white-space:pre-line">' + esc(modelAnswer) + '</div></div>';
if (ans.length) html += '<div class="asst-ans-sec">Из справки</div>';
}
if (ans.length) {
html += ans.map(function (a) {
return '<div class="asst-ans"><div class="asst-ans-q">' + esc(a.q) + '</div>' + esc(a.a) +