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:
@@ -25,16 +25,31 @@ function lessonAuthorText(r) {
|
||||
return upd ? `${name} · изменён ${notifyTime(r.updated_at)}` : name;
|
||||
}
|
||||
|
||||
function lessonAiBadgeHtml(r) {
|
||||
const st = r.ai_status;
|
||||
if (!st || st === 'none') return '';
|
||||
if (st === 'pending' || st === 'processing') return `<span class="ai-badge ai-badge-ic ai-badge-processing" title="ИИ проверяет текст по шаблону"><i data-lucide="loader"></i></span>`;
|
||||
if (st === 'done' && r.text_ai) return '<span class="ai-badge ai-badge-done" title="ИИ оформил текст по шаблону">ИИ</span>';
|
||||
if (st === 'skipped') return '<span class="ai-badge ai-badge-skipped" title="Текст уже соответствует шаблону">По шаблону</span>';
|
||||
if (st === 'reverted') return '<span class="ai-badge ai-badge-reverted" title="Возвращена исходная версия текста">Оригинал</span>';
|
||||
if (st === 'error') return `<span class="ai-badge ai-badge-error" title="Проверка по шаблону не удалась: ${esc(r.ai_error || 'ошибка ИИ')}">Ошибка ИИ</span>`;
|
||||
return `<span class="ai-badge ai-badge-ic ai-badge-${esc(st)}" title="Статус ИИ: ${esc(st)}">${esc(st)}</span>`;
|
||||
}
|
||||
|
||||
function lessonItemHtml(r) {
|
||||
const isToday = String(r.lesson_date).slice(0, 10) === lessonTodayIso();
|
||||
const author = lessonAuthorText(r);
|
||||
const canRevert = !!r.text_original && r.text_original !== r.text;
|
||||
return `
|
||||
<div class="lesson-item" data-lesson="${r.id}">
|
||||
<div class="lesson-item-head">
|
||||
<span class="lesson-group">${esc(r.group_name || '')}</span>
|
||||
<span class="lesson-when"><i data-lucide="clock-3"></i>${esc(lessonWhenText(r))}</span>
|
||||
${isToday ? '<span class="lesson-badge today">сегодня</span>' : ''}
|
||||
${lessonAiBadgeHtml(r)}
|
||||
<div class="lesson-actions">
|
||||
<button type="button" class="btn-link" data-versions="${r.id}">История</button>
|
||||
${canRevert ? `<button type="button" class="btn-link" data-revert="${r.id}" title="Вернуть исходный текст тьютора">↩ Оригинал</button>` : ''}
|
||||
<button type="button" class="btn-link" data-edit="${r.id}">Редактировать</button>
|
||||
${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''}
|
||||
</div>
|
||||
@@ -108,6 +123,15 @@ async function deleteLesson(id) {
|
||||
loadLessons();
|
||||
}
|
||||
|
||||
async function revertLessonAi(id) {
|
||||
if (!confirm('Вернуть исходный текст отчёта (до оформления по шаблону)?')) return;
|
||||
const res = await fetch(`${API}/api/lesson-reports/${id}/ai/revert`, { method: 'POST', headers: hdr() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { showToast(data.error || 'Не удалось вернуть оригинал'); return; }
|
||||
showToast('Возвращён исходный текст отчёта');
|
||||
loadLessons();
|
||||
}
|
||||
|
||||
function onLessonReportSaved() {
|
||||
loadLessons();
|
||||
}
|
||||
@@ -115,6 +139,10 @@ function onLessonReportSaved() {
|
||||
document.getElementById('lessonsList').addEventListener('click', e => {
|
||||
const edit = e.target.closest('[data-edit]');
|
||||
if (edit) { openLessonModal({ id: edit.dataset.edit }); return; }
|
||||
const versions = e.target.closest('[data-versions]');
|
||||
if (versions) { openLessonVersions(versions.dataset.versions); return; }
|
||||
const revert = e.target.closest('[data-revert]');
|
||||
if (revert) { revertLessonAi(revert.dataset.revert); return; }
|
||||
const del = e.target.closest('[data-del]');
|
||||
if (del) { deleteLesson(del.dataset.del); }
|
||||
});
|
||||
@@ -131,6 +159,26 @@ document.getElementById('searchInput').addEventListener('input', () => {
|
||||
searchTimer = setTimeout(() => { page = 1; loadLessons(); }, 350);
|
||||
});
|
||||
|
||||
let lessonLiveSource = null;
|
||||
|
||||
function connectLessonLive() {
|
||||
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
|
||||
const connect = () => {
|
||||
const t = sessionStorage.getItem('authToken');
|
||||
if (!t) return;
|
||||
lessonLiveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
|
||||
lessonLiveSource.addEventListener('lesson_report_status', () => loadLessons());
|
||||
lessonLiveSource.onerror = () => {
|
||||
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
|
||||
setTimeout(connect, 5000);
|
||||
};
|
||||
};
|
||||
connect();
|
||||
window.addEventListener('beforeunload', () => { if (lessonLiveSource) lessonLiveSource.close(); });
|
||||
}
|
||||
|
||||
connectLessonLive();
|
||||
|
||||
(async () => {
|
||||
if (!(await checkAuth())) return;
|
||||
buildSidebar(document.body.dataset.page);
|
||||
|
||||
Reference in New Issue
Block a user