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:
@@ -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');
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user