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
+271 -24
View File
@@ -5,7 +5,7 @@ const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert');
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker } = require('./worker');
const { createZipWriter, renderStudentReport } = require('./student-report');
const { createStorage, mimeFor } = require('./storage');
@@ -101,6 +101,7 @@ function invalidateSessions() { cacheDrop('session:'); }
const EVENTS_CHANNEL = 'whatido:events';
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo';
const LESSON_AI_WAKE_CHANNEL = 'whatido:wake:lesson-ai';
function createWorkerBus(channel) {
return {
@@ -123,6 +124,17 @@ function writeFrame(event, data) {
}
function dispatchEvent(payload) {
if (payload && payload.type === 'lesson_report_status') {
writeFrame('lesson_report_status', {
id: payload.id,
ai_status: payload.status,
ai_error: payload.error || null,
text: payload.text === undefined ? null : payload.text,
text_ai: payload.text_ai === undefined ? null : payload.text_ai,
ts: Date.now()
});
return;
}
if (payload && payload.type === 'ai_status') {
writeFrame('ai_status', {
id: payload.id,
@@ -143,6 +155,11 @@ function broadcastEntryChanged() {
.catch(err => console.error('Publish failed:', err.message));
}
function broadcastLessonChanged() {
cache.publish(EVENTS_CHANNEL, { type: 'lessons_changed' })
.catch(err => console.error('Publish failed:', err.message));
}
function broadcastAiStatus(payload) {
cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload })
.catch(err => console.error('Publish failed:', err.message));
@@ -194,6 +211,7 @@ const NOTIFY_TYPES = {
'backup.create': { label: 'Создан архив бэкапа', hint: 'Архив данных скачан из админ-панели', icon: 'download', level: 'info', enabled: false, admin: true },
'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true },
'lesson.report': { label: 'Отчёт о занятии', hint: 'Тьютор сохранил отчёт о том, что прошли на занятии', icon: 'notebook-pen', level: 'info', enabled: true, admin: false },
'lesson.ai.formatted': { label: 'Отчёт оформлен по шаблону', hint: 'ИИ привёл текст отчёта о занятии к деловому виду', icon: 'file-check', level: 'info', enabled: false, admin: false },
};
function notifySettingKey(type) {
@@ -1236,6 +1254,11 @@ async function ensureLessonReportsTable() {
lesson_date DATE NOT NULL,
lesson_time TIME,
text TEXT NOT NULL,
text_original TEXT,
text_ai TEXT,
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
author_id INT REFERENCES users(id) ON DELETE SET NULL,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
@@ -1245,8 +1268,27 @@ async function ensureLessonReportsTable() {
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL');
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS lesson_time TIME');
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()');
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT');
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT');
await pool.query("ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'");
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ');
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT');
await pool.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date)');
await pool.query('CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC)');
await pool.query(`CREATE TABLE IF NOT EXISTS lesson_report_versions (
id SERIAL PRIMARY KEY,
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
text TEXT NOT NULL,
source VARCHAR(20) NOT NULL DEFAULT 'manual',
author_id INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query('CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC)');
await pool.query("INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING");
await pool.query(
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING',
['lesson_ai_prompt', LESSON_AI_DEFAULT_PROMPT]
);
}
async function ensurePhotoOriginalColumn() {
@@ -1786,6 +1828,12 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
return res.status(400).json({ error: 'notify_retention_days должен быть целым числом от 1 до 365' });
}
}
if (key === 'lesson_ai_enabled' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: 'lesson_ai_enabled должен быть true или false' });
}
if (key === 'lesson_ai_prompt' && String(value).length > 8000) {
return res.status(400).json({ error: 'lesson_ai_prompt длиннее 8000 символов' });
}
if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: `${key} должен быть true или false` });
}
@@ -1914,9 +1962,51 @@ const PHOTO_AI_DEVICE_PREFS = ['auto', 'cuda', 'cpu'];
const PHOTO_AI_DEFAULT_STRENGTH = 0.7;
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const AI_CALL_TIMEOUT_MS = Math.max(5000, parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10) || 120000);
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
let entryAutoChecker = null;
let photoWorker = null;
let lessonAiChecker = null;
function wakeLessonAiWorker() {
if (lessonAiChecker) lessonAiChecker.notify();
}
async function onLessonAiDone({ row, status, text, aiText, error }) {
invalidateLessonReports();
cache.publish(EVENTS_CHANNEL, {
type: 'lesson_report_status',
id: row.id,
status,
error: error || null,
text,
text_ai: aiText || null,
}).catch(err => console.error('Publish failed:', err.message));
const original = String(row.text_original || row.text || '').trim();
if (status === 'done' && aiText) {
const d = textDiff(original, text || '');
if (logAudit) {
await logAudit(null, 'lesson_report.ai.format', {
id: row.id,
source: 'ai',
changed: d.changed,
fields: ['text'],
changes: d.changed
? [{ field: 'description', label: 'Текст отчёта', stats: d.stats, diff: d.segments, truncated: d.truncated }]
: []
});
}
await saveLessonReportVersion(row.id, text, 'ai', row.author_id || null);
await pushNotification({
type: 'lesson.ai.formatted',
title: `Отчёт оформлен по шаблону: ${row.group_name}`,
body: String(text || '').slice(0, 300),
link: 'lessons.html',
target: { lesson_report_id: row.id, lesson_date: String(row.lesson_date).slice(0, 10) },
branchId: row.branch_id,
});
}
}
async function getAiPrompt() {
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT);
@@ -1947,30 +2037,33 @@ function normalizeOpenAiBase(base) {
return b;
}
async function aiCorrectText(text) {
async function aiComplete(systemPrompt, userText, maxTokensOverride) {
const profile = await getActiveAiProfile();
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
let model = AI_MODEL;
const headers = { 'Content-Type': 'application/json' };
let maxTokens = Math.min(256, Math.max(128, text.length + 64));
let maxTokens = Math.min(256, Math.max(128, userText.length + 64));
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, text.length * 2 + 512));
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, userText.length * 2 + 512));
}
if (maxTokensOverride) maxTokens = Math.max(maxTokens, maxTokensOverride);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), AI_CALL_TIMEOUT_MS);
try {
const systemPrompt = await getAiPrompt();
const res = await fetch(url, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
{ role: 'user', content: userText }
],
temperature: 0.1,
max_tokens: maxTokens
@@ -1978,7 +2071,17 @@ async function aiCorrectText(text) {
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
return data.choices?.[0]?.message?.content?.trim() || text;
return data.choices?.[0]?.message?.content?.trim() || '';
} finally {
clearTimeout(timer);
}
}
async function aiCorrectText(text) {
try {
const systemPrompt = await getAiPrompt();
const out = await aiComplete(systemPrompt, text);
return out || text;
} catch (e) {
console.error('AI correct error:', e);
throw e;
@@ -2339,11 +2442,24 @@ function normalizeRestoreData(data) {
lesson_date: reqDate(x.lesson_date),
lesson_time: optTime(x.lesson_time),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX),
text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX),
ai_status: optStr(x.ai_status, 20),
ai_checked_at: optTs(x.ai_checked_at),
ai_error: optStr(x.ai_error, 500),
author_id: optInt(x.author_id, 0, 2147483647),
branch_id: optInt(x.branch_id, 0, 2147483647),
created_at: optTs(x.created_at),
updated_at: optTs(x.updated_at),
}));
const lesson_report_versions = (data.lesson_report_versions || []).map(x => ({
id: reqInt(x.id),
lesson_report_id: reqInt(x.lesson_report_id),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
source: optStr(x.source, 20),
author_id: optInt(x.author_id, 0, 2147483647),
created_at: optTs(x.created_at),
}));
const settings = {};
for (const [k, v] of Object.entries(data.settings || {})) {
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
@@ -2363,7 +2479,7 @@ function normalizeRestoreData(data) {
created_at: optTs(x.created_at),
finished_at: optTs(x.finished_at),
}));
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports };
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions };
}
const BACKUP_TTL_MS = 30 * 60 * 1000;
@@ -2397,7 +2513,7 @@ function sweepBackupStorage() {
async function buildBackupArchive() {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try {
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr] = await Promise.all([
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'),
@@ -2413,10 +2529,11 @@ async function buildBackupArchive() {
pool.query('SELECT * FROM share_links ORDER BY id'),
pool.query('SELECT * FROM photo_jobs ORDER BY id'),
pool.query('SELECT * FROM lesson_reports ORDER BY id'),
pool.query('SELECT * FROM lesson_report_versions ORDER BY id'),
]);
const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; });
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows };
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows };
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
await storage.downloadAll(path.join(staging, 'uploads'));
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
@@ -2573,6 +2690,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
try {
await client.query('BEGIN');
await client.query('DELETE FROM project_files');
await client.query('DELETE FROM lesson_report_versions');
await client.query('DELETE FROM lesson_reports');
await client.query('DELETE FROM entries');
await client.query('DELETE FROM modules');
@@ -2635,8 +2753,16 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
const ex = await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id]);
if (!ex.rowCount) continue;
await client.query(
'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, text, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9)',
[x.id, x.group_id, x.lesson_date, x.lesson_time, x.text, x.author_id, x.branch_id, x.created_at, x.updated_at]
'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)',
[x.id, x.group_id, x.lesson_date, x.lesson_time, x.text, x.text_original, x.text_ai, x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.author_id, x.branch_id, x.created_at, x.updated_at]
);
}
for (const x of ndata.lesson_report_versions) {
const ex = await client.query('SELECT 1 FROM lesson_reports WHERE id = $1', [x.lesson_report_id]);
if (!ex.rowCount) continue;
await client.query(
'INSERT INTO lesson_report_versions (id, lesson_report_id, text, source, author_id, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
[x.id, x.lesson_report_id, x.text, x.source || 'manual', x.author_id, x.created_at]
);
}
for (const x of ndata.photo_jobs) {
@@ -2679,7 +2805,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[k, String(v ?? '')]
);
}
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports']) {
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions']) {
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
}
@@ -3628,6 +3754,8 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
// --- Lesson reports (отчёт о занятии) ---
const LESSON_REPORT_TEXT_MAX = 5000;
const LESSON_REPORT_LIST_TTL_MS = 30 * 1000;
const LESSON_AI_VERSION_LIMIT = 50;
const LESSON_AI_DEFAULT_PROMPT = 'Ты — редактор деловых отчётов образовательного центра.\n\nТвоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.\n\nШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:\nОтчёт о проведённом занятии\nДата: <дата занятия>\nГруппа: <название группы>\nТемы: <перечень тем>\nПрактика: <задания>\nДомашнее задание: <что задано>\n\nПРАВИЛА:\n1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.\n2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.\n3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.\n4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.\n5. Не добавляй приветствия, подписи и какие-либо пояснения.\n6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.';
async function lessonReportGroup(user, groupId) {
const gid = Number(groupId);
@@ -3676,6 +3804,22 @@ function parseLessonReportTime(v) {
return s.length === 5 ? s + ':00' : s;
}
async function saveLessonReportVersion(reportId, text, source, userId) {
if (!text || !text.trim()) return null;
const { rows } = await pool.query(
`INSERT INTO lesson_report_versions (lesson_report_id, text, source, author_id)
VALUES ($1, $2, $3, $4) RETURNING *`,
[reportId, text, source, userId || null]
);
await pool.query(
`DELETE FROM lesson_report_versions WHERE lesson_report_id = $1 AND id NOT IN (
SELECT id FROM lesson_report_versions WHERE lesson_report_id = $1 ORDER BY id DESC LIMIT $2
)`,
[reportId, LESSON_AI_VERSION_LIMIT]
);
return rows[0];
}
app.get('/api/lesson-reports', requireAuth, async (req, res) => {
const { date_from, date_to, group_id, search, limit, offset } = req.query;
const conditions = [];
@@ -3697,6 +3841,7 @@ app.get('/api/lesson-reports', requireAuth, async (req, res) => {
const rows = await cacheWrap(key, LESSON_REPORT_LIST_TTL_MS, async () => {
const qparams = params.slice();
let q = `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.text, lr.author_id,
lr.text_original, lr.text_ai, lr.ai_status, lr.ai_checked_at, lr.ai_error,
lr.created_at, lr.updated_at, g.name AS group_name,
u.name AS author_name, u.username AS author_username
${from}
@@ -3718,7 +3863,7 @@ app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => {
});
app.post('/api/lesson-reports', requireAuth, async (req, res) => {
const { group_id, lesson_date, lesson_time, text } = req.body || {};
const { group_id, lesson_date, lesson_time, text, ai_check } = req.body || {};
const grp = await lessonReportGroup(req.user, group_id);
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
const date = parseLessonReportDate(lesson_date);
@@ -3735,28 +3880,38 @@ app.post('/api/lesson-reports', requireAuth, async (req, res) => {
if (existing.length) {
return res.status(409).json({ error: 'За эту группу и дату отчёт уже есть — откройте его для редактирования', id: existing[0].id });
}
const aiWanted = ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false';
const { rows } = await pool.query(
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, author_id, branch_id)
VALUES ($1, $2::date, $3, $4, $5, $6) RETURNING *`,
[grp.group.id, date, time, body, req.user.id || null, grp.group.branch_id || null]
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id)
VALUES ($1, $2::date, $3, $4, $5, NULL, $6::text, CASE WHEN $6::text = 'none' THEN NULL ELSE now() END, NULL, $7, $8) RETURNING *`,
[
grp.group.id, date, time, body,
aiWanted ? body : null,
aiWanted ? 'pending' : 'none',
req.user.id || null,
grp.group.branch_id || null,
]
);
await logAudit(req, 'lesson_report.create', { id: rows[0].id, group_id: grp.group.id, lesson_date: date });
const created = rows[0];
await saveLessonReportVersion(created.id, body, 'manual', req.user.id);
await logAudit(req, 'lesson_report.create', { id: created.id, group_id: grp.group.id, lesson_date: date, ai_status: created.ai_status });
invalidateLessonReports();
if (aiWanted) wakeLessonAiWorker();
await pushNotification({
type: 'lesson.report',
title: `Отчёт о занятии: ${grp.group.name}`,
body: body.slice(0, 300),
link: 'lessons.html',
target: { lesson_report_id: rows[0].id, group_id: grp.group.id, lesson_date: date },
target: { lesson_report_id: created.id, group_id: grp.group.id, lesson_date: date },
branchId: grp.group.branch_id,
});
res.status(201).json(rows[0]);
res.status(201).json(created);
});
app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
const { report, error, code } = await lessonReportById(req.user, req.params.id);
if (!report) return res.status(code || 404).json({ error });
const { lesson_date, lesson_time, text } = req.body || {};
const { lesson_date, lesson_time, text, ai_check } = req.body || {};
const curDate = String(report.lesson_date).slice(0, 10);
let date = curDate;
if (lesson_date !== undefined && lesson_date !== null && lesson_date !== '') {
@@ -3778,16 +3933,97 @@ app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
);
if (clash.length) return res.status(409).json({ error: 'За эту группу и дату уже есть другой отчёт' });
}
const nextTime = time === undefined ? report.lesson_time : time;
const aiWanted = !!body && ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false';
const { rows } = await pool.query(
`UPDATE lesson_reports SET
lesson_date = $1::date,
lesson_time = $2,
text = COALESCE($3, text),
text_original = CASE WHEN $4::boolean THEN $3::text ELSE text_original END,
text_ai = CASE WHEN $4::boolean THEN NULL ELSE text_ai END,
ai_status = CASE WHEN $4::boolean THEN 'pending'::varchar ELSE ai_status END,
ai_checked_at = CASE WHEN $4::boolean THEN now() ELSE ai_checked_at END,
ai_error = CASE WHEN $4::boolean THEN NULL ELSE ai_error END,
updated_at = now()
WHERE id = $4 RETURNING *`,
[date, time === undefined ? report.lesson_time : time, body, report.id]
WHERE id = $5 RETURNING *`,
[date, nextTime, body, aiWanted, report.id]
);
await logAudit(req, 'lesson_report.update', { id: report.id, lesson_date: date });
const updated = rows[0];
if (body) await saveLessonReportVersion(report.id, body, 'manual', req.user.id);
await logAudit(req, 'lesson_report.update', { id: report.id, lesson_date: date, ai_status: updated.ai_status });
invalidateLessonReports();
if (aiWanted) wakeLessonAiWorker();
res.json(updated);
});
app.get('/api/lesson-reports/:id/versions', requireAuth, async (req, res) => {
const { report, error, code } = await lessonReportById(req.user, req.params.id);
if (!report) return res.status(code || 404).json({ error });
const { rows } = await pool.query(
`SELECT v.id, v.lesson_report_id, v.text, v.source, v.created_at, u.name AS author_name, u.username AS author_username
FROM lesson_report_versions v
LEFT JOIN users u ON u.id = v.author_id
WHERE v.lesson_report_id = $1
ORDER BY v.id DESC LIMIT $2`,
[report.id, LESSON_AI_VERSION_LIMIT]
);
res.json({ items: rows, current: report.text });
});
app.post('/api/lesson-reports/:id/versions/:versionId/restore', requireAuth, async (req, res) => {
const { report, error, code } = await lessonReportById(req.user, req.params.id);
if (!report) return res.status(code || 404).json({ error });
const vid = Number(req.params.versionId);
if (!Number.isInteger(vid) || vid < 1) return res.status(400).json({ error: 'Некорректный идентификатор версии' });
const { rows: vrows } = await pool.query(
'SELECT * FROM lesson_report_versions WHERE id = $1 AND lesson_report_id = $2',
[vid, report.id]
);
if (!vrows.length) return res.status(404).json({ error: 'Версия не найдена' });
const version = vrows[0];
const { rows } = await pool.query(
`UPDATE lesson_reports SET text = $1, text_ai = NULL, ai_status = 'reverted', ai_error = NULL,
ai_checked_at = now(), updated_at = now()
WHERE id = $2 RETURNING *`,
[version.text, report.id]
);
const d = textDiff(report.text || '', version.text);
await saveLessonReportVersion(report.id, version.text, 'restore', req.user.id);
await logAudit(req, 'lesson_report.version.restore', {
id: report.id,
source: 'ai_revert',
changed: d.changed,
fields: d.changed ? ['text'] : [],
changes: d.changed
? [{ field: 'description', label: 'Текст отчёта', stats: d.stats, diff: d.segments, truncated: d.truncated }]
: []
});
invalidateLessonReports();
res.json(rows[0]);
});
app.post('/api/lesson-reports/:id/ai/revert', requireAuth, async (req, res) => {
const { report, error, code } = await lessonReportById(req.user, req.params.id);
if (!report) return res.status(code || 404).json({ error });
if (!report.text_original) return res.status(400).json({ error: 'Оригинал текста недоступен' });
const { rows } = await pool.query(
`UPDATE lesson_reports SET text = text_original, text_ai = NULL, ai_status = 'reverted',
ai_error = NULL, ai_checked_at = now(), updated_at = now()
WHERE id = $1 RETURNING *`,
[report.id]
);
const d = textDiff(report.text || '', rows[0].text || '');
await saveLessonReportVersion(report.id, rows[0].text, 'restore', req.user.id);
await logAudit(req, 'lesson_report.ai.revert', {
id: report.id,
source: 'ai_revert',
changed: d.changed,
fields: d.changed ? ['text'] : [],
changes: d.changed
? [{ field: 'description', label: 'Текст отчёта', stats: d.stats, diff: d.segments, truncated: d.truncated }]
: []
});
invalidateLessonReports();
res.json(rows[0]);
});
@@ -6727,4 +6963,15 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
});
photoWorker.start();
console.log('Photo enhance worker started');
lessonAiChecker = createLessonReportChecker({
pool,
getSetting,
logAudit,
aiUrl: AI_URL,
defaultPrompt: LESSON_AI_DEFAULT_PROMPT,
bus: createWorkerBus(LESSON_AI_WAKE_CHANNEL),
onDone: onLessonAiDone,
});
lessonAiChecker.start();
console.log('Lesson report AI worker started');
})();