fix(assistant): таймаут генерации тестов/карточек 15с был мал → 502

Симптом: POST /api/assistant/questions отдавал 502 «Не удалось сгенерировать
вопросы» ровно через ~15с. Причина: callLLM имел жёсткий таймаут 15с, а
бесплатная модель (owl-alpha) на генерацию 2200 токенов JSON порой тратит
больше — abort по таймауту, failover не выручал. Чат-ответам 15с хватает,
а генерации — нет.

callLLM/callLLMFailover получили опц. параметр timeoutMs (деф. 15с — чат не
тронут). questionsFromText → 45с, flashcardsFromText → 40с. Клиент req()
без своего таймаута, дождётся ответа.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 22:06:37 +03:00
parent 368cf30d58
commit c3be921dfb
@@ -421,11 +421,11 @@ function bumpUsage(field) {
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
/* Возвращает { text, error } — error: 'off'|'rate_limit'|'http'|'timeout'|'network'|'empty'|null. */
async function callLLM(messages, maxTokens, override) {
async function callLLM(messages, maxTokens, override, timeoutMs) {
const cfg = override || llmConfig();
if (typeof fetch !== 'function' || !cfg.on) return { text: null, error: 'off' };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
const timer = setTimeout(() => ctrl.abort(), timeoutMs || 15000);
try {
const r = await fetch(cfg.url, {
method: 'POST',
@@ -451,12 +451,12 @@ function _recordFailover(failed, served, reason) {
}
function _clearFailover() { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
async function callLLMFailover(messages, maxTokens) {
async function callLLMFailover(messages, maxTokens, timeoutMs) {
const cfgs = providersOrdered();
if (!cfgs.length) return { text: null, error: 'off' };
let last = { text: null, error: 'off' }, firstErr = null;
for (let i = 0; i < cfgs.length; i++) {
last = await callLLM(messages, maxTokens, cfgs[i]);
last = await callLLM(messages, maxTokens, cfgs[i], timeoutMs);
if (i === 0) firstErr = last.error;
if (last.text) {
if (i === 0) _clearFailover(); // активный работает — снимаем флаг
@@ -760,7 +760,7 @@ async function flashcardsFromText(req, res) {
'Верни СТРОГО JSON-массив из ' + count + ' объектов вида {"front":"...","back":"..."} без markdown и пояснений. ' +
'front — короткий вопрос, back — краткий ответ (1–2 предложения). По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.';
let rr;
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1600); }
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1600, 40000); }
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
const raw = rr && rr.text;
let cards = [];
@@ -800,7 +800,7 @@ async function questionsFromText(req, res) {
'РОВНО 4 варианта; correct — индекс правильного (0..3); ровно один правильный. ' +
'По-русски, формулы в LaTeX между $...$. Никакого текста вне JSON, без markdown.';
let rr;
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 2200); }
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 2200, 45000); }
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
const raw = rr && rr.text;
let questions = [];