feat(lesson-ai): проверка отчёта о занятии по шаблону + история версий
Галочка «Проверить по шаблону» в окне отчёта отправляет текст модели: совпал с шаблоном — остаётся как есть (skipped), не совпал — переписывается в деловом виде (done). Обработка идёт в фоне, HTTP-запрос не ждёт модель, оригинал тьютора сохраняется в text_original. - схема: text_original/text_ai/ai_status/ai_checked_at/ai_error в lesson_reports, таблица lesson_report_versions, ensureLessonReportsTable() - настройки lesson_ai_enabled и lesson_ai_prompt (раздел sec-lesson-ai), значения только 'true'/'false' - worker.js: createLessonReportChecker (FOR UPDATE OF lr SKIP LOCKED, до 3 попыток), хук назовён notifyEvent — notify в createPhotoEnhanceWorker уже занят будильником - server.js: wakeLessonAiWorker, onLessonAiDone (версия, аудит с diff, уведомление lesson.ai.formatted, SSE lesson_report_status), маршруты /versions, /versions/:id/restore и /ai/revert - aiComplete вместо aiCorrectText: общий вызов модели с таймаутом - бэкап/восстановление: lesson_reports и lesson_report_versions в payload - фронтенд: openLessonVersions/restoreLessonVersion в admin.js, бейджи статусов в lessons.js, лейблы аудита, renderAuditPager - docs: раздел 3d в AGENTS.md и Agent Workflow, пункт в README - тесты: контракт lesson-report и настройки уведомления в api.smoketest.js
This commit is contained in:
@@ -766,4 +766,269 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
||||
return { start, notify, getStats, getInfo };
|
||||
}
|
||||
|
||||
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };
|
||||
function createLessonReportChecker({ pool, getSetting, logAudit, aiUrl, model, defaultPrompt, bus, onDone }) {
|
||||
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 || '';
|
||||
const TIMEOUT_MS = Math.max(5000, parseInt(process.env.LESSON_AI_TIMEOUT_MS || '120000', 10) || 120000);
|
||||
const MIN_CHARS = 20;
|
||||
const MAX_INPUT_CHARS = 4000;
|
||||
const TEXT_MAX = 5000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
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, formatted: 0, unchanged: 0, errors: 0, last_at: null, last_error: null };
|
||||
const attempts = new Map();
|
||||
|
||||
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();
|
||||
if (bus) bus.publish();
|
||||
}
|
||||
|
||||
if (bus) bus.subscribe(() => notify());
|
||||
|
||||
async function isEnabled() {
|
||||
return String(await getSetting('lesson_ai_enabled', 'true')) !== 'false';
|
||||
}
|
||||
|
||||
async function claimNext() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`SELECT lr.id, lr.text, lr.text_original, lr.lesson_date, lr.lesson_time, lr.author_id,
|
||||
g.name AS group_name, g.branch_id
|
||||
FROM lesson_reports lr
|
||||
JOIN groups g ON g.id = lr.group_id
|
||||
WHERE lr.ai_status = 'pending'
|
||||
ORDER BY lr.id ASC LIMIT 1 FOR UPDATE OF lr SKIP LOCKED`
|
||||
);
|
||||
if (!rows.length) {
|
||||
await client.query('COMMIT');
|
||||
return null;
|
||||
}
|
||||
await client.query(`UPDATE lesson_reports 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) {
|
||||
const b = String(base || '').trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(b)) return null;
|
||||
if (!/\/v1$/i.test(b)) return b + '/v1';
|
||||
return b;
|
||||
}
|
||||
|
||||
async function callModel(userText, systemPrompt) {
|
||||
const profile = await resolveActiveProfile();
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
let url;
|
||||
let model;
|
||||
let maxTokens;
|
||||
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}`;
|
||||
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, userText.length * 2 + 512));
|
||||
} else {
|
||||
url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||||
model = MODEL;
|
||||
maxTokens = Math.min(1536, Math.max(512, userText.length + 512));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 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: userText },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
const choice = data.choices && data.choices[0];
|
||||
return (choice && choice.message && choice.message.content || '').trim();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutput(out) {
|
||||
let s = String(out || '').trim();
|
||||
if (!s) return '';
|
||||
s = s.replace(/^```[a-zA-Z]*\s*/, '').replace(/```$/, '').trim();
|
||||
s = s.replace(/^(?:итоговый|результативный|готовый|итог)\s*текст\s*(?:отчёта|отчета|report)?\s*[:—-]\s*/i, '').trim();
|
||||
s = s.replace(/^["'«”]+/, '').replace(/["'«»"]+$/, '').trim();
|
||||
s = s.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n');
|
||||
return s;
|
||||
}
|
||||
async function finish(row, status, text, error, aiText) {
|
||||
await pool.query(
|
||||
`UPDATE lesson_reports SET
|
||||
text = $1, text_ai = $2, ai_status = $3, ai_error = $4, ai_checked_at = now(), updated_at = now()
|
||||
WHERE id = $5`,
|
||||
[text, aiText, status, error || null, row.id]
|
||||
);
|
||||
attempts.delete(row.id);
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = error || null;
|
||||
if (onDone) await onDone({ row, status, text, aiText, error: error || null });
|
||||
}
|
||||
|
||||
async function processOne(row) {
|
||||
const original = String(row.text_original || row.text || '').trim();
|
||||
if (original.length < MIN_CHARS) {
|
||||
await finish(row, 'skipped', original, null, null);
|
||||
stats.unchanged++;
|
||||
return true;
|
||||
}
|
||||
const prompt = (await getSetting('lesson_ai_prompt', DEFAULT_PROMPT)) || DEFAULT_PROMPT;
|
||||
const date = String(row.lesson_date).slice(0, 10);
|
||||
const time = row.lesson_time ? String(row.lesson_time).slice(0, 5) : '';
|
||||
const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original;
|
||||
const context = [
|
||||
`Группа: ${row.group_name || '—'}`,
|
||||
`Дата занятия: ${date}`,
|
||||
time ? `Время занятия: ${time}` : '',
|
||||
'',
|
||||
'Текст отчёта:',
|
||||
input,
|
||||
].filter(l => l !== '').join('\n');
|
||||
try {
|
||||
const out = normalizeOutput(await callModel(context, prompt));
|
||||
if (!out || out.length > TEXT_MAX) {
|
||||
const reason = out ? 'Модель вернула слишком длинный текст' : 'Модель вернула пустой ответ';
|
||||
await finish(row, 'error', original, reason, null);
|
||||
stats.errors++;
|
||||
return true;
|
||||
}
|
||||
const changed = out !== original;
|
||||
await finish(row, changed ? 'done' : 'skipped', changed ? out : original, null, changed ? out : null);
|
||||
stats.checks++;
|
||||
if (changed) stats.formatted++; else stats.unchanged++;
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||
const tries = (attempts.get(row.id) || 0) + 1;
|
||||
if (tries < MAX_ATTEMPTS) {
|
||||
attempts.set(row.id, tries);
|
||||
await pool.query(`UPDATE lesson_reports SET ai_status = 'pending', ai_error = $1 WHERE id = $2`, [message, row.id]);
|
||||
if (logAudit) await logAudit(null, 'lesson_report.ai.retry', { id: row.id, attempt: tries, error: message });
|
||||
return false;
|
||||
}
|
||||
await finish(row, 'error', original, message, null);
|
||||
stats.errors++;
|
||||
if (logAudit) await logAudit(null, 'lesson_report.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('Lesson AI claim error:', e.message);
|
||||
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('Lesson AI process error:', e.message);
|
||||
} finally {
|
||||
processing = false;
|
||||
currentId = null;
|
||||
}
|
||||
if (!ok) await sleep(IDLE_MAX_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function resetStale() {
|
||||
return pool.query(`UPDATE lesson_reports SET ai_status = 'pending' WHERE ai_status = 'processing'`);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startedAt = Date.now();
|
||||
resetStale()
|
||||
.catch((e) => console.error('Lesson AI reset error:', e.message))
|
||||
.finally(() => { loop().catch((e) => console.error('Lesson AI loop error:', e.message)); });
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function getInfo() {
|
||||
return {
|
||||
started_at: startedAt ? new Date(startedAt).toISOString() : null,
|
||||
uptime_ms: startedAt ? Date.now() - startedAt : 0,
|
||||
processing,
|
||||
current_id: currentId,
|
||||
config: { idle_min_ms: IDLE_MIN_MS, idle_max_ms: IDLE_MAX_MS, timeout_ms: TIMEOUT_MS, model: MODEL, ai_url: AI_URL },
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
|
||||
return { start, stop, notify, getInfo };
|
||||
}
|
||||
|
||||
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker };
|
||||
|
||||
Reference in New Issue
Block a user