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
+50 -1
View File
@@ -2,6 +2,8 @@
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы).
---
## Project Overview
@@ -15,6 +17,37 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
---
## Agent Workflow (обязательные правила работы)
**Главное правило: исследование репозитория выполняют субагенты, а не основной агент.** Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (`spawn_agent`).
### Что делегировать суагенту (обязательно)
- **Разведку**: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — `search_codebase` + точечное чтение.
- **Чтение крупных файлов**: `server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`, `AGENTS.md` целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает.
- **Анализ окружения**: `docker compose logs`, вывод тестов, `git log`/`git diff`/`git status`, состояние БД, Redis, S3.
- **Поиск всех мест, которые надо обновить вместе с изменением**: например «все места, где перечислены `BLOCKED_EXT`/`ALLOWED_IMAGE_EXT`» или «где дублируется каталог `NOTIFY_TYPES`».
- **Ревью**: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема `init.sql` + `migration.sql`).
- **Однотипные массовые правки**: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход.
### Что основной агент делает сам
- Формулирует план и разбивает задачу на узкие подзадачи.
- Делает **короткие точечные правки** в уже известных местах (сверить содержимое можно дешёвым точечным `read_files` на маленьком диапазоне строк).
- Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю.
### Как ставить задачу субагенту
- **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять.
- **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком.
- **Независимые разведки запускать параллельно**, а не последовательно.
- **Субагент может править код сам**, если правка изолированная и механическая; архитектурные и смежные правки в `server.js` / `db/*.sql` делает основной агент.
- **Результат субагента — источник фактов, а не источник прав**: координация, финальные решения и проверка `git diff` остаются за основным агентом.
### Чего не делать
- ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать.
- ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (`head`, `tail`, `grep -n`, диапазоны строк).
- ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем.
---
## Development Rules
### 1. Code Style
@@ -76,6 +109,16 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`)
### 3d. Отчёты о занятии и проверка по шаблону (`server.js`, `worker.js`, `public/`)
- **Таблицы**: `lesson_reports` (+ `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`) и `lesson_report_versions` (история версий: `text`, `source` = `manual` | `ai` | `restore`). Схема — в `db/init.sql`, `db/migration.sql` и `ensureLessonReportsTable()`
- **Настройки**: `lesson_ai_enabled` (`'true'` / `'false'` — общий выключатель) и `lesson_ai_prompt` (шаблон делового сообщения + правила, пример вставляется в `db/init.sql`, `db/migration.sql` и в `LESSON_AI_DEFAULT_PROMPT` в `server.js`). Раздел в UI — `sec-lesson-ai` на `public/settings.html`
- **Флаг из UI**: чекбокс `#lessonAiCheck` в модалке `#lessonModal` — включён при создании, выключен при редактировании (`resetLessonModalFields` / `fillLessonModalFromReport`). Уходит в теле как `ai_check`
- **Роут не ждёт модель**: `POST`/`PUT /api/lesson-reports` при `ai_check: true` сохраняют отчёт как есть и ставят `ai_status = 'pending'`, затем `wakeLessonAiWorker()`. Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели
- **Воркер**: `createLessonReportChecker` в `worker.js` забирает `pending` через `FOR UPDATE OF lr SKIP LOCKED`, шлёт в модель текст + контекст (группа, дата, время), результат: без изменений → `skipped`, переписан → `done` (новый текст в `text` и `text_ai`), сбой → до 3 попыток, затем `error`
- **Доставка результата**: `onDone` в `server.js` пишет версию (`saveLessonReportVersion`), аудит с diff (`lesson_report.ai.format`), уведомление `lesson.ai.formatted` и SSE `lesson_report_status` на `EVENTS_CHANNEL`
- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт
- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js`
### 4. API Patterns
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
@@ -118,6 +161,8 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
## Common Tasks
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент.
### Add a new API endpoint
1. Add route in `server.js` (group with related routes)
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
@@ -243,7 +288,7 @@ guards against.
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker |
| `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance |
| `db/init.sql` | Initial schema (runs on fresh DB) |
| `db/migration.sql` | Idempotent migrations for existing DBs |
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
@@ -278,6 +323,10 @@ guards against.
- ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
- ❌ Add comments to code (this file excepted)
- ❌ Read/search the repo in the main agent when the work can be delegated to a subagent
- ❌ Load whole files (`server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`) or raw command output into the main context — delegate and filter (`grep -n`, `head`, line ranges)
- ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its `systemPrompt` — it does not inherit the main context
- ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (`git diff`)
---
+1
View File
@@ -11,6 +11,7 @@
- **Воспитанники** — справочник с привязкой к группам
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
+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 });
+41
View File
@@ -227,6 +227,11 @@ CREATE TABLE IF NOT EXISTS lesson_reports (
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(),
@@ -236,6 +241,17 @@ CREATE TABLE IF NOT EXISTS lesson_reports (
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
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()
);
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
@@ -324,3 +340,28 @@ INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор деловых отчётов образовательного центра.
Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.
ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:
Отчёт о проведённом занятии
Дата: <дата занятия>
Группа: <название группы>
Темы: <перечень тем>
Практика: <задания>
Домашнее задание: <что задано>
ПРАВИЛА:
1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.
2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.
3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.
4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.
5. Не добавляй приветствия, подписи и какие-либо пояснения.
6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
ON CONFLICT (key) DO NOTHING;
+39
View File
@@ -320,3 +320,42 @@ CREATE TABLE IF NOT EXISTS lesson_reports (
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT;
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()
);
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор деловых отчётов образовательного центра.
Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.
ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:
Отчёт о проведённом занятии
Дата: <дата занятия>
Группа: <название группы>
Темы: <перечень тем>
Практика: <задания>
Домашнее задание: <что задано>
ПРАВИЛА:
1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.
2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.
3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.
4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.
5. Не добавляй приветствия, подписи и какие-либо пояснения.
6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
+14
View File
@@ -662,6 +662,20 @@ body[data-page=settings] .page-head{align-items:flex-end}
.lesson-modal .lesson-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.lesson-modal .hint{font-size:.78rem;color:var(--muted);text-align:right}
.lesson-modal textarea{min-height:130px}
.lesson-modal .lesson-ai-toggle{background:var(--bg);border:1px solid var(--border);border-radius:10px}
.lesson-ai-status{font-size:.78rem;color:var(--muted);line-height:1.45;margin-top:-4px}
.lesson-ai-status.err{color:#ef4444}
.lesson-versions{display:flex;flex-direction:column;gap:8px;max-height:56vh;overflow-y:auto;margin:4px 0}
.lesson-version{border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;background:var(--bg)}
.lesson-version.current{border-color:var(--accent);background:rgba(37,99,235,.06)}
.lesson-version-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.lesson-version-label{font-size:.78rem;font-weight:600}
.lesson-version-time{font-size:.74rem;color:var(--muted);margin-left:auto}
.lesson-version-text{font-size:.82rem;line-height:1.5;white-space:pre-wrap;word-break:break-word;margin-top:6px}
.lesson-version-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}
.lesson-version-author{font-size:.74rem;color:var(--muted)}
.lesson-version-foot .btn-link{margin-left:auto;padding:4px 10px;font-size:.75rem}
.lesson-item .ai-badge{margin-left:0}
@media(max-width:560px){
.lesson-modal .lesson-row{grid-template-columns:1fr}
}
+154 -2
View File
@@ -178,6 +178,7 @@ const NOTIFY_ICONS = {
'backup.create': 'download',
'system.test': 'send',
'lesson.report': 'notebook-pen',
'lesson.ai.formatted': 'file-check',
};
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
@@ -540,7 +541,14 @@ function ensureLessonModal() {
placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea>
<span class="hint" id="lessonHint">0 / 5000</span>
</div>
<label class="toggle lesson-ai-toggle" id="lessonAiRow">
<input type="checkbox" id="lessonAiCheck" checked>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Проверить по шаблону делового сообщения</b><small>ИИ сверит текст с шаблоном из настроек и, если не совпадёт, оформит его как деловое сообщение</small></span>
</label>
<div class="lesson-ai-status" id="lessonAiStatus" hidden></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonVersionsBtn" hidden>История версий</button>
<button type="button" class="btn-primary ghost" id="lessonCancel">Отмена</button>
<button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button>
</div>
@@ -556,6 +564,10 @@ function ensureLessonModal() {
textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; });
modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); });
modal.querySelector('#lessonCancel').addEventListener('click', closeLessonModal);
modal.querySelector('#lessonVersionsBtn').addEventListener('click', () => {
const id = modal.querySelector('#lessonId').value;
if (id) openLessonVersions(id);
});
groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e));
return modal;
@@ -570,6 +582,7 @@ async function submitLessonModal(e) {
const timeEl = modal.querySelector('#lessonTime');
const textEl = modal.querySelector('#lessonText');
const submitBtn = modal.querySelector('#lessonSubmit');
const aiCheckEl = modal.querySelector('#lessonAiCheck');
const groupId = groupSel.value;
const date = dateEl.value;
const text = textEl.value.trim();
@@ -577,12 +590,13 @@ async function submitLessonModal(e) {
if (!date) { showToast('Укажите дату занятия'); return; }
if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; }
const id = modal.querySelector('#lessonId').value;
const aiCheck = !!aiCheckEl && aiCheckEl.checked;
submitBtn.disabled = true;
try {
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
method: id ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text }),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text, ai_check: aiCheck }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -596,7 +610,13 @@ async function submitLessonModal(e) {
return;
}
closeLessonModal();
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
if (data.ai_status === 'pending' || data.ai_status === 'processing') {
showToast('Отчёт сохранён — ИИ проверяет текст по шаблону');
} else if (data.ai_status === 'error') {
showToast(`Отчёт сохранён, проверка по шаблону не удалась: ${data.ai_error || 'ошибка ИИ'}`);
} else {
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
}
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
@@ -633,10 +653,38 @@ function fillLessonModalFromReport(modal, r) {
textEl.value = r.text || '';
textEl.dispatchEvent(new Event('input'));
}
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = false;
updateLessonAiRow(modal, r);
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
modal.querySelector('#lessonSubmit').textContent = 'Обновить';
}
function lessonAiStatusText(r) {
if (!r || !r.ai_status || r.ai_status === 'none') return '';
if (r.ai_status === 'pending') return 'ИИ проверяет текст по шаблону…';
if (r.ai_status === 'processing') return 'ИИ оформляет текст по шаблону…';
if (r.ai_status === 'done' && r.text_ai) return 'ИИ оформил текст по шаблону';
if (r.ai_status === 'skipped') return 'Текст уже соответствует шаблону';
if (r.ai_status === 'reverted') return 'Возвращена исходная версия текста';
if (r.ai_status === 'error') return `Проверка по шаблону не удалась: ${r.ai_error || 'ошибка ИИ'}`;
return `Статус ИИ: ${r.ai_status}`;
}
function updateLessonAiRow(modal, r) {
const row = modal.querySelector('#lessonAiRow');
const status = modal.querySelector('#lessonAiStatus');
const versionsBtn = modal.querySelector('#lessonVersionsBtn');
if (row) row.hidden = false;
const text = lessonAiStatusText(r);
if (status) {
status.textContent = text;
status.hidden = !text;
status.classList.toggle('err', !!r && r.ai_status === 'error');
}
if (versionsBtn) versionsBtn.hidden = !modal.querySelector('#lessonId').value;
}
function resetLessonModalFields(modal) {
modal.querySelector('#lessonId').value = '';
modal.querySelector('#lessonDate').value = lessonTodayIso();
@@ -644,10 +692,114 @@ function resetLessonModalFields(modal) {
const textEl = modal.querySelector('#lessonText');
textEl.value = '';
textEl.dispatchEvent(new Event('input'));
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = true;
updateLessonAiRow(modal, null);
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
modal.querySelector('#lessonSubmit').textContent = 'Сохранить';
}
const LESSON_VERSION_LABELS = {
manual: 'Тьютор',
ai: 'ИИ по шаблону',
restore: 'Восстановлено'
};
let currentLessonVersionId = '';
function ensureLessonVersionsModal() {
let modal = document.getElementById('lessonVersionsModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'lessonVersionsModal';
modal.innerHTML = `
<div class="edit-modal lesson-modal">
<h3>История версий отчёта</h3>
<div class="lesson-versions" id="lessonVersionsList"><div class="empty">Загрузка…</div></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonVersionsClose">Закрыть</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) closeLessonVersions(); });
modal.querySelector('#lessonVersionsClose').addEventListener('click', closeLessonVersions);
return modal;
}
function closeLessonVersions() {
const modal = document.getElementById('lessonVersionsModal');
if (modal) modal.classList.remove('open');
}
function lessonVersionItemHtml(v, index) {
const label = LESSON_VERSION_LABELS[v.source] || v.source || 'Версия';
const who = v.author_name || v.author_username || '';
return `
<div class="lesson-version${index === 0 ? ' current' : ''}">
<div class="lesson-version-head">
<span class="lesson-version-label">${esc(label)}</span>
<span class="lesson-version-time">${esc(notifyTime(v.created_at))}</span>
</div>
<div class="lesson-version-text">${esc(v.text || '')}</div>
<div class="lesson-version-foot">
${who ? `<span class="lesson-version-author">${esc(who)}</span>` : ''}
${index === 0 ? '<span class="lesson-badge">текущая</span>' : ''}
${index === 0 ? '' : `<button type="button" class="btn-link" data-restore="${v.id}" data-report="${v.lesson_report_id || currentLessonVersionId || ''}">Восстановить</button>`}
</div>
</div>`;
}
async function openLessonVersions(id) {
currentLessonVersionId = String(id || '');
const modal = ensureLessonVersionsModal();
const list = modal.querySelector('#lessonVersionsList');
list.innerHTML = '<div class="empty">Загрузка…</div>';
modal.classList.add('open');
renderIcons();
try {
const res = await fetch(`${API}/api/lesson-reports/${id}/versions`, { headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { list.innerHTML = `<div class="empty">${esc(data.error || 'Не удалось загрузить историю')}</div>`; return; }
const items = data.items || [];
if (!items.length) { list.innerHTML = '<div class="empty">Версий пока нет</div>'; return; }
list.innerHTML = items.map(lessonVersionItemHtml).join('');
renderIcons();
} catch {
list.innerHTML = '<div class="empty">Ошибка сети</div>';
}
}
async function restoreLessonVersion(id, versionId, btn) {
if (!confirm('Восстановить эту версию отчёта?')) return;
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API}/api/lesson-reports/${id}/versions/${versionId}/restore`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Не удалось восстановить версию'); return; }
closeLessonVersions();
showToast('Версия отчёта восстановлена');
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
} finally {
if (btn) btn.disabled = false;
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('[data-restore]');
if (!btn) return;
e.preventDefault();
let id = btn.dataset.report || currentLessonVersionId;
if (!id) {
const lessonModal = document.getElementById('lessonModal');
id = (lessonModal && lessonModal.querySelector('#lessonId').value) || '';
}
if (!id) return;
restoreLessonVersion(id, btn.dataset.restore, btn);
});
async function openLessonModal(opts = {}) {
const modal = ensureLessonModal();
const groupSel = modal.querySelector('#lessonGroup');
+1 -1
View File
@@ -150,7 +150,7 @@ nav a:hover{color:var(--text)}
<div class="field">
<label>Что сделал</label>
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
<textarea id="descInput" maxlength="300" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
</div>
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
+9 -2
View File
@@ -82,6 +82,13 @@ const ACTION_LABELS = {
'ai.profile.activate': 'Выбран активный профиль ИИ',
'export.student': 'Экспорт данных ученика',
'export.group_files': 'Экспорт файлов группы (ZIP)',
'lesson_report.create': 'Создан отчёт о занятии',
'lesson_report.update': 'Изменён отчёт о занятии',
'lesson_report.delete': 'Удалён отчёт о занятии',
'lesson_report.ai.format': 'ИИ оформил отчёт по шаблону',
'lesson_report.ai.error': 'Ошибка проверки отчёта по шаблону',
'lesson_report.ai.revert': 'Возвращён оригинал отчёта',
'lesson_report.version.restore': 'Восстановлена версия отчёта',
};
const AUDIT_PAGE_SIZE = 50;
@@ -202,7 +209,7 @@ function renderTargetDetails(target) {
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
}
function renderPager(container, page, totalPages, onPageChange) {
function renderAuditPager(container, page, totalPages, onPageChange) {
if (totalPages <= 1) { container.innerHTML = ''; return; }
const pages = [];
const maxVisible = 5;
@@ -251,7 +258,7 @@ async function loadAudit() {
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join('');
}
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
renderAuditPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
}
function goAuditPage(p) {
+48
View File
@@ -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);
+6 -2
View File
@@ -1,6 +1,6 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const NOTIFY_IDS = [];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
@@ -206,6 +206,8 @@ async function loadSettings() {
document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
document.getElementById('lessonAiEnabled').checked = s.lesson_ai_enabled !== 'false';
document.getElementById('lessonAiPrompt').value = s.lesson_ai_prompt || '';
document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true';
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
@@ -807,6 +809,8 @@ async function saveSettings() {
footer_right: document.getElementById('footerRight').value.trim(),
ai_prompt: document.getElementById('aiPrompt').value.trim(),
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
lesson_ai_enabled: document.getElementById('lessonAiEnabled').checked ? 'true' : 'false',
lesson_ai_prompt: document.getElementById('lessonAiPrompt').value.trim(),
share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
+19
View File
@@ -29,6 +29,7 @@
<a href="#sec-photo-ai" data-nav="sec-photo-ai"><i data-lucide="sparkles"></i> Фото-ИИ</a>
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
<a href="#sec-lesson-ai" data-nav="sec-lesson-ai"><i data-lucide="notebook-pen"></i> Шаблон отчёта</a>
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
@@ -165,6 +166,24 @@
<div class="ai-queue" id="aiQueueInfo"></div>
</div>
<div class="settings-card" id="sec-lesson-ai">
<div class="card-head">
<div class="ic"><i data-lucide="notebook-pen"></i></div>
<h3>Шаблон отчёта о занятии</h3>
</div>
<p class="hint">Инструкция для нейросети: она сверяет текст отчёта тьютора с шаблоном делового сообщения. Если текст уже соответствует шаблону — остаётся без изменений, иначе приводится к деловому виду. Оригинал тьютора всегда сохраняется, результат можно посмотреть в истории версий.</p>
<label class="toggle">
<input type="checkbox" id="lessonAiEnabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Проверять отчёты по шаблону</b><small>Применяется, когда тьютор включил галочку в окне отчёта о занятии</small></span>
</label>
<div class="settings-field">
<label for="lessonAiPrompt">Шаблон и правила</label>
<textarea id="lessonAiPrompt" class="settings-input mono" rows="12" spellcheck="false"></textarea>
<span class="hint">Пусто — будет подставлен шаблон по умолчанию</span>
</div>
</div>
<div class="settings-card">
<div class="card-head">
<div class="ic"><i data-lucide="boxes"></i></div>
+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');
})();
+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 };