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
+43
View File
@@ -210,6 +210,49 @@ async function main() {
body: enhanceAi.data,
});
// Отчёты о занятии: контракт проверки по шаблону и истории версий.
// Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь
// и то, что при ai_check:false текст остаётся нетронутым.
const lrGroups = await api('/api/groups', { token });
const gid = lrGroups.data[0] && lrGroups.data[0].id;
if (gid) {
const date = '2019-05-17';
await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token })
.then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' })));
const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.';
const plain = await api('/api/lesson-reports', {
token, method: 'POST',
body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false },
});
ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null',
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
{ status: plain.status, ai_status: plain.data.ai_status });
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
ok('lesson-report: история версий содержит исходный текст',
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
&& versions.data.items.some(v => v.text === plainText && v.source === 'manual'),
{ status: versions.status, items: (versions.data.items || []).length });
const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' });
ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status);
const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' });
ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status);
const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' });
ok('lesson-report: удаление', del.status === 200, del.status);
} else {
ok('lesson-report: есть группа для проверки', false, 'no groups');
}
const lessonNotifyMeta = await api('/api/notifications/meta', { token });
// /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы
ok('notifications: настройка lesson.ai.formatted заведена',
(lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'),
(lessonNotifyMeta.data.types || []).map(t => t.key));
const logout = await api('/api/auth/logout', { token, method: 'POST' });
ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token });