From bc0ed1892f1f6129aa950ae3cba90773ba000d49 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 24 Jun 2026 15:02:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=B0=D0=B2=D1=82=D0=BE-?= =?UTF-8?q?=D0=B7=D0=B4=D0=BE=D1=80=D0=BE=D0=B2=D1=8C=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=BE=D0=B2=20+=20?= =?UTF-8?q?=D1=80=D1=83=D1=87=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20(=D1=84=D0=B8=D1=87=D0=B0=204/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый модуль assistant-health.js (по образцу classroom-cleanup): каждые 15 мин пингует каждого провайдера (pingLLM) → app_settings.assistant_health { id:{ok,at,error,ms,fails} }. Авто-понижение: если активный провайдер не отвечает 2+ раза подряд, а есть здоровый рабочий запасной — автоматически переключает assistant_active и пишет assistant_failover (баннер «health»). schedule() из server.js (unref). Админка: тумблер «Авто-проверка провайдеров», кнопка «Проверить сейчас» (POST /admin/assistant/health → runHealth), цветной индикатор здоровья на каждой карточке провайдера (зелёный/красный + время/ошибка в title). keyless-шлюзы и провайдеры без ключа учтены. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/assistant-health.js | 58 ++++++++++++++++++++++ backend/src/controllers/adminController.js | 14 +++++- backend/src/routes/admin.js | 1 + backend/src/server.js | 3 ++ frontend/js/admin/sections/assistant.js | 16 ++++-- js/api.js | 3 +- 6 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 backend/src/assistant-health.js diff --git a/backend/src/assistant-health.js b/backend/src/assistant-health.js new file mode 100644 index 0000000..c88fd3d --- /dev/null +++ b/backend/src/assistant-health.js @@ -0,0 +1,58 @@ +'use strict'; +/* Авто-здоровье LLM-провайдеров Квантика: периодический пинг каждого провайдера + * (lightweight pingLLM) + авто-понижение активного, если он стабильно не отвечает, + * а есть здоровый запасной. Результат — в app_settings.assistant_health (JSON-карта + * { id: { ok, at, error, ms, fails } }). Авто-переключение пишет тот же + * assistant_failover, что показывает баннер в админке. Период — 15 мин (вкл. по + * умолчанию; app_settings.assistant_health_enabled='0' выключает). */ +const db = require('./db/db'); +const logger = require('./utils/logger'); + +function _get(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key=?').get(k); return r && r.value != null ? r.value : null; } +function _set(k, v) { db.prepare('INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)').run(k, v); } +function _providers() { try { return JSON.parse(_get('assistant_providers') || '[]') || []; } catch (e) { return []; } } +function _enabled() { return _get('assistant_health_enabled') !== '0'; } +function _noKey(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || '') || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); } + +async function runHealthCheck() { + if (!_enabled()) return { skipped: true }; + const provs = _providers(); + if (!provs.length) return { providers: 0 }; + const a = require('./controllers/assistantController'); + let prev = {}; try { prev = JSON.parse(_get('assistant_health') || '{}') || {}; } catch (e) {} + const health = {}; + for (const p of provs) { + // нет ключа и не keyless-шлюз — не пингуем (в FAQ-режиме), помечаем как «нет ключа» + if (!p.key && !_noKey(p.url)) { health[p.id] = { ok: false, at: new Date().toISOString(), error: 'нет ключа', ms: 0, fails: (prev[p.id] && prev[p.id].fails || 0) }; continue; } + const t0 = Date.now(); + let r; try { r = await a.pingLLM({ url: p.url, model: p.model, key: p.key }); } catch (e) { r = { ok: false, error: 'сбой' }; } + const ok = !!(r && r.ok); + health[p.id] = { + ok, at: new Date().toISOString(), ms: Date.now() - t0, + error: ok ? null : String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 140), + fails: ok ? 0 : ((prev[p.id] && prev[p.id].fails || 0) + 1), + }; + } + _set('assistant_health', JSON.stringify(health)); + + // авто-понижение активного: 2+ подряд неудач И есть здоровый рабочий запасной + const activeId = _get('assistant_active'); + const active = provs.find(p => p.id === activeId); + if (active && health[activeId] && !health[activeId].ok && health[activeId].fails >= 2) { + const healthy = provs.find(p => p.id !== activeId && health[p.id] && health[p.id].ok && (p.key || _noKey(p.url))); + if (healthy) { + _set('assistant_active', healthy.id); + _set('assistant_failover', JSON.stringify({ at: new Date().toISOString(), failedId: activeId, failedName: active.name, servedId: healthy.id, servedName: healthy.name, reason: 'health', auto: true })); + logger.info('assistant-health auto-demote', { from: active.name, to: healthy.name, fails: health[activeId].fails }); + } + } + return { providers: provs.length, health }; +} + +function schedule() { + const run = () => { runHealthCheck().catch(() => {}); }; + setTimeout(run, 90_000).unref(); // первый прогон через 1.5 мин после старта + setInterval(run, 15 * 60 * 1000).unref(); // далее каждые 15 минут +} + +module.exports = { runHealthCheck, schedule }; diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index a5d25e7..3495d3c 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -1008,6 +1008,8 @@ function getAssistant(_req, res) { providers, activeId, active, rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1', memory: _aset('assistant_memory') !== '0', socratic: _aset('assistant_socratic') === '1', + healthEnabled: _aset('assistant_health_enabled') !== '0', + health: (() => { try { return JSON.parse(_aset('assistant_health') || '{}') || {}; } catch (e) { return {}; } })(), chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'), }); @@ -1021,6 +1023,7 @@ function saveAssistant(req, res) { if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0'); if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0'); if (typeof b.socratic === 'boolean') set('assistant_socratic', b.socratic ? '1' : '0'); + if (typeof b.healthEnabled === 'boolean') set('assistant_health_enabled', b.healthEnabled ? '1' : '0'); if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} } audit(req, 'assistant.config', 'assistant', 'настройки'); res.json({ ok: true }); @@ -1210,6 +1213,15 @@ function applyModels(req, res) { res.json({ ok: true, count: clean.length }); } +/* POST /api/admin/assistant/health — прогнать проверку здоровья провайдеров сейчас */ +async function runHealth(req, res) { + try { + const r = await require('../assistant-health').runHealthCheck(); + audit(req, 'assistant.health', 'assistant', 'ручная проверка'); + res.json({ ok: true, result: r }); + } catch (e) { res.status(500).json({ ok: false, error: e.message || 'ошибка' }); } +} + /* POST /api/admin/assistant/active { id } — выбрать активного провайдера */ function setActiveProvider(req, res) { const id = String((req.body && req.body.id) || ''); @@ -1323,5 +1335,5 @@ module.exports = { getTopics, createTopic, updateTopic, deleteTopic, broadcast, getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels, - scanModels, probeModel, applyModels, + scanModels, probeModel, applyModels, runHealth, }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 73e8ce0..0b958ae 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -25,6 +25,7 @@ router.get('/assistant/models', ctrl.getProviderModels); router.post('/assistant/scan', ctrl.scanModels); router.post('/assistant/probe', ctrl.probeModel); router.post('/assistant/models/apply', ctrl.applyModels); +router.post('/assistant/health', ctrl.runHealth); router.get('/imggen', ctrl.getImggen); router.put('/imggen', ctrl.saveImggen); router.post('/imggen/test', ctrl.testImggen); diff --git a/backend/src/server.js b/backend/src/server.js index ebd643f..4c75a34 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -535,6 +535,9 @@ require('./ws-server').attach(server); /* ── Ретеншн данных доски: чистка штрихов/картинок старых завершённых сессий ── */ try { require('./classroom-cleanup').schedule(); } catch (e) { logger.error('classroom-cleanup schedule error', { err: e.message }); } +/* ── Авто-здоровье LLM-провайдеров Квантика: пинг + авто-понижение упавшего активного ── */ +try { require('./assistant-health').schedule(); } catch (e) { logger.error('assistant-health schedule error', { err: e.message }); } + /* ── Graceful shutdown ── */ function shutdown(signal) { logger.info(`${signal} received — shutting down gracefully`); diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index bd25dae..ebcec50 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -65,10 +65,11 @@ var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {} var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || []; + var health = cfg.health || {}; // ── Баннер failover ── if (cfg.failover) { - var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' }; + var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка', health: 'не прошёл авто-проверку' }; var when = ''; try { when = new Date(fo.at).toLocaleString('ru'); } catch (e) {} var ban = document.createElement('div'); ban.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:11px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5;display:flex;align-items:flex-start;gap:12px'; @@ -132,7 +133,8 @@ '' + '' + '' + - '
' + (cfg.chunks || 0) + ' фрагментов
' + + '' + + '
' + (cfg.chunks || 0) + ' фрагментов
' + '
Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.
' + '
Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '
'; host.appendChild(sc); @@ -158,9 +160,11 @@ var lim = L ? '
' + fmtLimits(L) + '
' : '
лимиты: загрузка…
'; + var h = health[p.id]; + var hdot = h ? '' : ''; return '
' + '
' + SPARK + '
' + - '
' + esc(p.name || 'Провайдер') + + '
' + esc(p.name || 'Провайдер') + hdot + (act ? 'активен' : '') + (p.hasKey ? 'ключ есть' : p.noKey ? 'без ключа' : 'нет ключа') + '
' + '
' + esc(p.model || '') + '
' + ksel + lim + '
' + @@ -265,6 +269,12 @@ Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); }); Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); Q('#asst-socratic').addEventListener('change', function () { LS.adminSaveAssistant({ socratic: Q('#asst-socratic').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); + Q('#asst-health').addEventListener('change', function () { LS.adminSaveAssistant({ healthEnabled: Q('#asst-health').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); + Q('#asst-healthrun').addEventListener('click', async function () { + var btn = Q('#asst-healthrun'); btn.disabled = true; btn.textContent = 'Проверяю…'; + try { await LS.adminAssistantHealth(); LS.toast('Проверка завершена', 'success'); render(); } + catch (e) { LS.toast('Ошибка проверки', 'error'); btn.disabled = false; btn.textContent = 'Проверить провайдеров сейчас'; } + }); Q('#asst-reindex').addEventListener('click', async function () { var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…'; try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); } diff --git a/js/api.js b/js/api.js index 887c0ea..29be45f 100644 --- a/js/api.js +++ b/js/api.js @@ -1186,7 +1186,7 @@ window.LS = { assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, - adminAssistantScan, adminAssistantProbe, adminAssistantApplyModels, + adminAssistantScan, adminAssistantProbe, adminAssistantApplyModels, adminAssistantHealth, fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview, prepListTracks, prepMyTracks, prepStudentTracks, prepSetStudent, prepUnsetStudent, prepClassStatus, prepSetClass, escapeHtml, esc, @@ -1475,6 +1475,7 @@ async function adminAssistantModels(params) { const q = new URLSearchParams(para async function adminAssistantScan(id) { return req('POST', '/admin/assistant/scan', id ? { id } : {}); } async function adminAssistantProbe(id, model) { return req('POST', '/admin/assistant/probe', { id, model }); } async function adminAssistantApplyModels(models, reset) { return req('POST', '/admin/assistant/models/apply', reset ? { reset: true } : { models }); } +async function adminAssistantHealth() { return req('POST', '/admin/assistant/health', {}); } async function fcListDecks() { return req('GET', '/flashcards/decks'); } async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); } async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }