feat(assistant): несколько провайдеров ИИ + выбор активного + авто-перехват при лимите
Конфиг стал списком провайдеров (assistant_providers) + активный (assistant_active). llmConfig берёт активного; providersOrdered — активный первым, затем остальные с ключом; callLLMFailover перебирает их при 429/сетевой ошибке (второй ключ подхватывает при исчерпании квоты). Legacy мигрируется в список. Админ-раздел: список провайдеров (радио-активный, Тест/Изменить/Удалить) + форма с пресетами. Эндпоинты POST/DELETE /admin/assistant/provider(/:id), POST /admin/assistant/active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -230,12 +230,37 @@ function searchFaq(q, n) {
|
||||
/* Конфиг берём из app_settings (правится из админки без рестарта), с откатом
|
||||
* на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */
|
||||
function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } }
|
||||
function _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); }
|
||||
|
||||
/* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings.
|
||||
* Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */
|
||||
function _providers() {
|
||||
let arr = [];
|
||||
try { arr = JSON.parse(_setting('assistant_providers') || '[]'); } catch (e) {}
|
||||
if (!Array.isArray(arr)) arr = [];
|
||||
if (!arr.length) {
|
||||
const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions';
|
||||
const key = _setting('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY || '';
|
||||
const model = _setting('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile';
|
||||
arr = [{ id: 'p1', name: 'Провайдер 1', url, model, key }];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
/* Конфиги в порядке использования: активный первым, затем остальные с ключом
|
||||
* (для авто-перехвата при лимите/ошибке). */
|
||||
function providersOrdered() {
|
||||
const arr = _providers().filter(p => p && (p.key || _isLocal(p.url)));
|
||||
const activeId = _setting('assistant_active');
|
||||
const active = arr.filter(p => p.id === activeId);
|
||||
const rest = arr.filter(p => p.id !== activeId);
|
||||
return active.concat(rest).map(p => ({ id: p.id, name: p.name, url: p.url, key: p.key, model: p.model, local: _isLocal(p.url), on: true }));
|
||||
}
|
||||
function llmConfig() {
|
||||
const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions';
|
||||
const key = _setting('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY || '';
|
||||
const ordered = providersOrdered();
|
||||
if (ordered.length) return ordered[0];
|
||||
const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions';
|
||||
const model = _setting('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile';
|
||||
const local = /\/\/(localhost|127\.0\.0\.1)/.test(url);
|
||||
return { url, key, model, local, on: !!(key || local) };
|
||||
return { url, key: '', model, local: _isLocal(url), on: false };
|
||||
}
|
||||
|
||||
/* RAG: релевантные куски учебников (textbook_chunks) под вопрос.
|
||||
@@ -289,6 +314,21 @@ async function callLLM(messages, maxTokens, override) {
|
||||
} catch (e) { return { text: null, error: e.name === 'AbortError' ? 'timeout' : 'network' }; } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/* Перебор провайдеров: активный, затем остальные — при лимите/сетевой ошибке.
|
||||
* Останавливаемся на успехе или на «контентной» неудаче (пустой ответ). */
|
||||
const _RETRYABLE = { rate_limit: 1, http: 1, timeout: 1, network: 1 };
|
||||
async function callLLMFailover(messages, maxTokens) {
|
||||
const cfgs = providersOrdered();
|
||||
if (!cfgs.length) return { text: null, error: 'off' };
|
||||
let last = { text: null, error: 'off' };
|
||||
for (const c of cfgs) {
|
||||
last = await callLLM(messages, maxTokens, c);
|
||||
if (last.text) return last;
|
||||
if (!_RETRYABLE[last.error]) break; // не лимит/сеть — нет смысла пробовать другие
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
|
||||
async function pingLLM(override) {
|
||||
const cfg = override || llmConfig();
|
||||
@@ -350,7 +390,7 @@ async function askModel(q, hits, context, history, role, mode) {
|
||||
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 });
|
||||
return callLLM(msgs, 420);
|
||||
return callLLMFailover(msgs, 420);
|
||||
}
|
||||
|
||||
/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─
|
||||
@@ -367,7 +407,7 @@ async function ask(req, res) {
|
||||
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, sources: [] }); }
|
||||
if (!providersOrdered().length) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); }
|
||||
|
||||
const rag = ragContext(q);
|
||||
|
||||
@@ -416,14 +456,14 @@ function feedback(req, res) {
|
||||
* Генерирует учебные карточки из текста (модель → JSON). Карточки фронт
|
||||
* создаёт сам через существующий API флешкарт. */
|
||||
async function flashcardsFromText(req, res) {
|
||||
if (!llmConfig().on) return res.status(503).json({ error: 'LLM не настроена' });
|
||||
if (!providersOrdered().length) 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 rr = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
|
||||
const rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
|
||||
const raw = rr && rr.text;
|
||||
let cards = [];
|
||||
if (raw) {
|
||||
|
||||
Reference in New Issue
Block a user