const IDLE_MIN_MS = 2000; const IDLE_MAX_MS = 60000; const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10); const MAX_INPUT_CHARS = 2000; const MIN_TEXT_CHARS = 4; function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) { const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080'; const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf'; const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'; let started = false; let stopped = false; let idleMs = IDLE_MIN_MS; let wake = null; let processing = false; let currentId = null; let startedAt = null; const stats = { checks: 0, corrected: 0, unchanged: 0, errors: 0, last_at: null, last_error: null }; const attempts = new Map(); const MAX_ATTEMPTS = 3; const CONFIG = { idle_min_ms: IDLE_MIN_MS, idle_max_ms: IDLE_MAX_MS, request_timeout_ms: REQUEST_TIMEOUT_MS, max_input_chars: MAX_INPUT_CHARS, min_text_chars: MIN_TEXT_CHARS, max_attempts: MAX_ATTEMPTS, model: MODEL, ai_url: AI_URL, }; function sleep(ms) { return new Promise((resolve) => { const t = setTimeout(() => { wake = null; resolve(); }, ms); wake = () => { clearTimeout(t); wake = null; resolve(); }; }); } function notify() { if (wake) wake(); } async function isEnabled() { const v = await getSetting('ai_autocheck_enabled', 'true'); return String(v) !== 'false'; } async function claimNext() { const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( `SELECT id, description, description_original FROM entries WHERE ai_status = 'pending' AND deleted_at IS NULL ORDER BY id ASC LIMIT 1 FOR UPDATE SKIP LOCKED` ); if (!rows.length) { await client.query('COMMIT'); return null; } await client.query(`UPDATE entries SET ai_status = 'processing' WHERE id = $1`, [rows[0].id]); await client.query('COMMIT'); return rows[0]; } catch (e) { await client.query('ROLLBACK').catch(() => {}); throw e; } finally { client.release(); } } async function resolveActiveProfile() { try { const active = await getSetting('ai_active_profile', 'native'); if (active && active !== 'native') { const raw = await getSetting('ai_profiles', '[]'); const list = JSON.parse(raw || '[]'); if (Array.isArray(list)) { return list.find(p => p && p.id === active && p.base_url && p.model) || null; } } } catch (e) {} return null; } function normalizeOpenAiBase(base) { let b = String(base || '').trim().replace(/\/+$/, ''); if (!/^https?:\/\//i.test(b)) return null; if (!/\/v1$/i.test(b)) b += '/v1'; return b; } async function callModel(text, systemPrompt) { const profile = await resolveActiveProfile(); const headers = { 'Content-Type': 'application/json' }; let url; let model; if (profile) { const base = normalizeOpenAiBase(profile.base_url); if (!base) throw new Error('Некорректный base_url профиля ИИ'); url = `${base}/chat/completions`; model = profile.model; if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`; } else { url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`; model = MODEL; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const res = await fetch(url, { method: 'POST', headers, signal: controller.signal, body: JSON.stringify({ model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: text }, ], temperature: 0.1, max_tokens: Math.min(512, Math.max(64, text.length + 32)), }), }); if (!res.ok) throw new Error(`AI service error: ${res.status}`); const data = await res.json(); return (data.choices?.[0]?.message?.content || '').trim(); } finally { clearTimeout(timer); } } function sanitize(original, out) { if (!out) return original; const o = out.trim(); if (!o) return original; if (o.length > original.length * 4 + 80) return original; return o; } async function processOne(row) { const original = String(row.description_original ?? row.description ?? '').trim(); if (original.length < MIN_TEXT_CHARS) { await pool.query(`UPDATE entries SET ai_status = 'skipped', ai_checked_at = now() WHERE id = $1`, [row.id]); return true; } const prompt = (await getSetting('ai_prompt', DEFAULT_PROMPT)) || DEFAULT_PROMPT; const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original; try { const out = await callModel(input, prompt); const checked = sanitize(input, out); const changed = checked !== input; await pool.query( `UPDATE entries SET description_ai = $1, description = $1, ai_status = 'done', ai_error = NULL, ai_checked_at = now() WHERE id = $2`, [changed ? checked : original, row.id] ); attempts.delete(row.id); stats.checks++; if (changed) stats.corrected++; else stats.unchanged++; stats.last_at = new Date().toISOString(); stats.last_error = null; if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed }); return true; } catch (e) { const message = (e && e.message ? e.message : 'error').slice(0, 500); const tries = (attempts.get(row.id) || 0) + 1; stats.last_at = new Date().toISOString(); stats.last_error = message; if (tries < MAX_ATTEMPTS) { attempts.set(row.id, tries); await pool.query(`UPDATE entries SET ai_status = 'pending', ai_error = $1 WHERE id = $2`, [message, row.id]); if (logAudit) await logAudit(null, 'entry.ai.retry', { id: row.id, attempt: tries, error: message }); return false; } attempts.delete(row.id); stats.errors++; await pool.query( `UPDATE entries SET ai_status = 'error', ai_error = $1, ai_checked_at = now() WHERE id = $2`, [message, row.id] ); if (logAudit) await logAudit(null, 'entry.ai.error', { id: row.id, error: message }); return true; } } async function loop() { while (!stopped) { let row = null; try { if (!(await isEnabled())) { await sleep(IDLE_MAX_MS); continue; } row = await claimNext(); } catch (e) { console.error('AI auto-check claim error:', e); await sleep(IDLE_MAX_MS); continue; } if (!row) { await sleep(idleMs); idleMs = Math.min(idleMs * 2, IDLE_MAX_MS); continue; } idleMs = IDLE_MIN_MS; processing = true; currentId = row.id; let ok = true; try { ok = await processOne(row); } catch (e) { console.error('AI auto-check process error:', e); } finally { processing = false; currentId = null; } if (!ok) await sleep(IDLE_MAX_MS); } } async function resetStale() { await pool.query(`UPDATE entries SET ai_status = 'pending' WHERE ai_status = 'processing'`); } function start() { if (started) return; started = true; startedAt = Date.now(); resetStale() .catch((e) => console.error('AI auto-check reset error:', e)) .finally(() => { loop().catch((e) => console.error('AI auto-check loop error:', e)); }); } function getStats() { return { ...stats, processing }; } function getInfo() { return { started_at: startedAt ? new Date(startedAt).toISOString() : null, uptime_ms: startedAt ? Date.now() - startedAt : 0, processing, current_id: currentId, config: CONFIG, ...stats, }; } return { start, notify, getStats, getInfo }; } module.exports = { createEntryAutoChecker };