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:
dev
2026-10-04 00:12:24 +03:00
parent a84f33307e
commit b931c0a760
14 changed files with 962 additions and 33 deletions
+266 -1
View File
@@ -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 };