feat(assistant): авто-здоровье провайдеров + ручная проверка (фича 4/6)
Новый модуль 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 @@
|
||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' +
|
||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-memory" ' + (cfg.memory !== false ? 'checked' : '') + '> Персональная память об ученике (слабые темы, заметки)</label>' +
|
||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-socratic" ' + (cfg.socratic ? 'checked' : '') + '> Сократический режим: не решать задачи за ученика (теорию объясняет, задачи — наводит)</label>' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span></div>' +
|
||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-health" ' + (cfg.healthEnabled !== false ? 'checked' : '') + '> Авто-проверка провайдеров (каждые 15 мин): упавший активный автоматически уступает место здоровому</label>' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span><button id="asst-healthrun" class="asst-ib">Проверить провайдеров сейчас</button></div>' +
|
||||
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' +
|
||||
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
|
||||
host.appendChild(sc);
|
||||
@@ -158,9 +160,11 @@
|
||||
var lim = L
|
||||
? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</div>'
|
||||
: '<div class="asst-pclim" data-lim="' + p.id + '" style="opacity:.6">лимиты: загрузка…</div>';
|
||||
var h = health[p.id];
|
||||
var hdot = h ? '<span title="' + esc((h.ok ? 'отвечает' : (h.error || 'не отвечает')) + (h.at ? ' · ' + (function () { try { return new Date(h.at).toLocaleString('ru'); } catch (e) { return ''; } })() : '')) + '" style="display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;background:' + (h.ok ? '#059652' : '#e0335e') + ';margin-left:2px;align-self:center"></span>' : '';
|
||||
return '<div class="asst-pcard' + (act ? ' active' : '') + '">' +
|
||||
'<div class="asst-pcic">' + SPARK + '</div>' +
|
||||
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') +
|
||||
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') + hdot +
|
||||
(act ? '<span class="asst-bdg act">активен</span>' : '') +
|
||||
(p.hasKey ? '<span class="asst-bdg key">ключ есть</span>' : p.noKey ? '<span class="asst-bdg key">без ключа</span>' : '<span class="asst-bdg nokey">нет ключа</span>') + '</div>' +
|
||||
'<div class="asst-pcs">' + esc(p.model || '') + '</div>' + ksel + lim + '</div>' +
|
||||
@@ -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'); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user