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. This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы).
--- ---
## Project Overview ## 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 ## Development Rules
### 1. Code Style ### 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 - **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`) - **Аудит**: удаление/очистка уведомлений логируется (`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 ### 4. API Patterns
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **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` и фильтр не добавляется - **Филиалы**: `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 ## Common Tasks
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент.
### Add a new API endpoint ### Add a new API endpoint
1. Add route in `server.js` (group with related routes) 1. Add route in `server.js` (group with related routes)
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public 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 | | `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 | | `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 | | `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/init.sql` | Initial schema (runs on fresh DB) |
| `db/migration.sql` | Idempotent migrations for existing DBs | | `db/migration.sql` | Idempotent migrations for existing DBs |
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) | | `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
@@ -278,6 +323,10 @@ guards against.
- ❌ Expose DB port (5432) outside docker network - ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution - ❌ Use `eval`, `Function` constructor, or dynamic code execution
- ❌ Add comments to code (this file excepted) - ❌ 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-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат - **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников - **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции - **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`) - **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
+43
View File
@@ -210,6 +210,49 @@ async function main() {
body: enhanceAi.data, 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' }); const logout = await api('/api/auth/logout', { token, method: 'POST' });
ok('logout', logout.status === 200, logout.status); ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token }); 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_date DATE NOT NULL,
lesson_time TIME, lesson_time TIME,
text TEXT NOT NULL, 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, author_id INT REFERENCES users(id) ON DELETE SET NULL,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL, branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(), 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 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 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 ( CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, 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; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
ON CONFLICT (key) DO NOTHING; 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 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 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 .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 .hint{font-size:.78rem;color:var(--muted);text-align:right}
.lesson-modal textarea{min-height:130px} .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){ @media(max-width:560px){
.lesson-modal .lesson-row{grid-template-columns:1fr} .lesson-modal .lesson-row{grid-template-columns:1fr}
} }
+153 -1
View File
@@ -178,6 +178,7 @@ const NOTIFY_ICONS = {
'backup.create': 'download', 'backup.create': 'download',
'system.test': 'send', 'system.test': 'send',
'lesson.report': 'notebook-pen', 'lesson.report': 'notebook-pen',
'lesson.ai.formatted': 'file-check',
}; };
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' }; const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
@@ -540,7 +541,14 @@ function ensureLessonModal() {
placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea> placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea>
<span class="hint" id="lessonHint">0 / 5000</span> <span class="hint" id="lessonHint">0 / 5000</span>
</div> </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"> <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="button" class="btn-primary ghost" id="lessonCancel">Отмена</button>
<button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button> <button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button>
</div> </div>
@@ -556,6 +564,10 @@ function ensureLessonModal() {
textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; }); textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; });
modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); }); modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); });
modal.querySelector('#lessonCancel').addEventListener('click', 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()); groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e)); modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e));
return modal; return modal;
@@ -570,6 +582,7 @@ async function submitLessonModal(e) {
const timeEl = modal.querySelector('#lessonTime'); const timeEl = modal.querySelector('#lessonTime');
const textEl = modal.querySelector('#lessonText'); const textEl = modal.querySelector('#lessonText');
const submitBtn = modal.querySelector('#lessonSubmit'); const submitBtn = modal.querySelector('#lessonSubmit');
const aiCheckEl = modal.querySelector('#lessonAiCheck');
const groupId = groupSel.value; const groupId = groupSel.value;
const date = dateEl.value; const date = dateEl.value;
const text = textEl.value.trim(); const text = textEl.value.trim();
@@ -577,12 +590,13 @@ async function submitLessonModal(e) {
if (!date) { showToast('Укажите дату занятия'); return; } if (!date) { showToast('Укажите дату занятия'); return; }
if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; } if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; }
const id = modal.querySelector('#lessonId').value; const id = modal.querySelector('#lessonId').value;
const aiCheck = !!aiCheckEl && aiCheckEl.checked;
submitBtn.disabled = true; submitBtn.disabled = true;
try { try {
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, { const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
method: id ? 'PUT' : 'POST', method: id ? 'PUT' : 'POST',
headers: hdrJson(), 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(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
@@ -596,7 +610,13 @@ async function submitLessonModal(e) {
return; return;
} }
closeLessonModal(); closeLessonModal();
if (data.ai_status === 'pending' || data.ai_status === 'processing') {
showToast('Отчёт сохранён — ИИ проверяет текст по шаблону');
} else if (data.ai_status === 'error') {
showToast(`Отчёт сохранён, проверка по шаблону не удалась: ${data.ai_error || 'ошибка ИИ'}`);
} else {
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён'); showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
}
if (typeof onLessonReportSaved === 'function') onLessonReportSaved(); if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch { } catch {
showToast('Ошибка сети'); showToast('Ошибка сети');
@@ -633,10 +653,38 @@ function fillLessonModalFromReport(modal, r) {
textEl.value = r.text || ''; textEl.value = r.text || '';
textEl.dispatchEvent(new Event('input')); textEl.dispatchEvent(new Event('input'));
} }
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = false;
updateLessonAiRow(modal, r);
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта'; modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
modal.querySelector('#lessonSubmit').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) { function resetLessonModalFields(modal) {
modal.querySelector('#lessonId').value = ''; modal.querySelector('#lessonId').value = '';
modal.querySelector('#lessonDate').value = lessonTodayIso(); modal.querySelector('#lessonDate').value = lessonTodayIso();
@@ -644,10 +692,114 @@ function resetLessonModalFields(modal) {
const textEl = modal.querySelector('#lessonText'); const textEl = modal.querySelector('#lessonText');
textEl.value = ''; textEl.value = '';
textEl.dispatchEvent(new Event('input')); textEl.dispatchEvent(new Event('input'));
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = true;
updateLessonAiRow(modal, null);
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии'; modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
modal.querySelector('#lessonSubmit').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 = {}) { async function openLessonModal(opts = {}) {
const modal = ensureLessonModal(); const modal = ensureLessonModal();
const groupSel = modal.querySelector('#lessonGroup'); const groupSel = modal.querySelector('#lessonGroup');
+1 -1
View File
@@ -150,7 +150,7 @@ nav a:hover{color:var(--text)}
<div class="field"> <div class="field">
<label>Что сделал</label> <label>Что сделал</label>
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea> <textarea id="descInput" maxlength="300" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
</div> </div>
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value=""> <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': 'Выбран активный профиль ИИ', 'ai.profile.activate': 'Выбран активный профиль ИИ',
'export.student': 'Экспорт данных ученика', 'export.student': 'Экспорт данных ученика',
'export.group_files': 'Экспорт файлов группы (ZIP)', '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; 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>`; <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; } if (totalPages <= 1) { container.innerHTML = ''; return; }
const pages = []; const pages = [];
const maxVisible = 5; const maxVisible = 5;
@@ -251,7 +258,7 @@ async function loadAudit() {
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td> <td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join(''); </tr>`).join('');
} }
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage); renderAuditPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
} }
function goAuditPage(p) { function goAuditPage(p) {
+48
View File
@@ -25,16 +25,31 @@ function lessonAuthorText(r) {
return upd ? `${name} · изменён ${notifyTime(r.updated_at)}` : name; 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) { function lessonItemHtml(r) {
const isToday = String(r.lesson_date).slice(0, 10) === lessonTodayIso(); const isToday = String(r.lesson_date).slice(0, 10) === lessonTodayIso();
const author = lessonAuthorText(r); const author = lessonAuthorText(r);
const canRevert = !!r.text_original && r.text_original !== r.text;
return ` return `
<div class="lesson-item" data-lesson="${r.id}"> <div class="lesson-item" data-lesson="${r.id}">
<div class="lesson-item-head"> <div class="lesson-item-head">
<span class="lesson-group">${esc(r.group_name || '')}</span> <span class="lesson-group">${esc(r.group_name || '')}</span>
<span class="lesson-when"><i data-lucide="clock-3"></i>${esc(lessonWhenText(r))}</span> <span class="lesson-when"><i data-lucide="clock-3"></i>${esc(lessonWhenText(r))}</span>
${isToday ? '<span class="lesson-badge today">сегодня</span>' : ''} ${isToday ? '<span class="lesson-badge today">сегодня</span>' : ''}
${lessonAiBadgeHtml(r)}
<div class="lesson-actions"> <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> <button type="button" class="btn-link" data-edit="${r.id}">Редактировать</button>
${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''} ${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''}
</div> </div>
@@ -108,6 +123,15 @@ async function deleteLesson(id) {
loadLessons(); 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() { function onLessonReportSaved() {
loadLessons(); loadLessons();
} }
@@ -115,6 +139,10 @@ function onLessonReportSaved() {
document.getElementById('lessonsList').addEventListener('click', e => { document.getElementById('lessonsList').addEventListener('click', e => {
const edit = e.target.closest('[data-edit]'); const edit = e.target.closest('[data-edit]');
if (edit) { openLessonModal({ id: edit.dataset.edit }); return; } 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]'); const del = e.target.closest('[data-del]');
if (del) { deleteLesson(del.dataset.del); } if (del) { deleteLesson(del.dataset.del); }
}); });
@@ -131,6 +159,26 @@ document.getElementById('searchInput').addEventListener('input', () => {
searchTimer = setTimeout(() => { page = 1; loadLessons(); }, 350); 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 () => { (async () => {
if (!(await checkAuth())) return; if (!(await checkAuth())) return;
buildSidebar(document.body.dataset.page); 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 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 SPAM_MAX = 10080;
const BAN_REASONS = { const BAN_REASONS = {
'honeypot': 'Антиспам-поле', 'honeypot': 'Антиспам-поле',
@@ -206,6 +206,8 @@ async function loadSettings() {
document.getElementById('footerRight').value = s.footer_right || ''; document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || ''; document.getElementById('aiPrompt').value = s.ai_prompt || '';
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false'; 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('shareShowMessage').checked = s.share_show_student_message === 'true';
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true'; document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false'; document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
@@ -807,6 +809,8 @@ async function saveSettings() {
footer_right: document.getElementById('footerRight').value.trim(), footer_right: document.getElementById('footerRight').value.trim(),
ai_prompt: document.getElementById('aiPrompt').value.trim(), ai_prompt: document.getElementById('aiPrompt').value.trim(),
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false', 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_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false', share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
share_show_student_names: document.getElementById('shareShowNames').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-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-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-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-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-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> <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 class="ai-queue" id="aiQueueInfo"></div>
</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="settings-card">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="boxes"></i></div> <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 helmet = require('helmet');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert'); const heicConvert = require('heic-convert');
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker'); const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker } = require('./worker');
const { createZipWriter, renderStudentReport } = require('./student-report'); const { createZipWriter, renderStudentReport } = require('./student-report');
const { createStorage, mimeFor } = require('./storage'); const { createStorage, mimeFor } = require('./storage');
@@ -101,6 +101,7 @@ function invalidateSessions() { cacheDrop('session:'); }
const EVENTS_CHANNEL = 'whatido:events'; const EVENTS_CHANNEL = 'whatido:events';
const AI_WAKE_CHANNEL = 'whatido:wake:ai'; const AI_WAKE_CHANNEL = 'whatido:wake:ai';
const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo'; const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo';
const LESSON_AI_WAKE_CHANNEL = 'whatido:wake:lesson-ai';
function createWorkerBus(channel) { function createWorkerBus(channel) {
return { return {
@@ -123,6 +124,17 @@ function writeFrame(event, data) {
} }
function dispatchEvent(payload) { 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') { if (payload && payload.type === 'ai_status') {
writeFrame('ai_status', { writeFrame('ai_status', {
id: payload.id, id: payload.id,
@@ -143,6 +155,11 @@ function broadcastEntryChanged() {
.catch(err => console.error('Publish failed:', err.message)); .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) { function broadcastAiStatus(payload) {
cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload }) cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload })
.catch(err => console.error('Publish failed:', err.message)); .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 }, '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 }, '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.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) { function notifySettingKey(type) {
@@ -1236,6 +1254,11 @@ async function ensureLessonReportsTable() {
lesson_date DATE NOT NULL, lesson_date DATE NOT NULL,
lesson_time TIME, lesson_time TIME,
text TEXT NOT NULL, 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, author_id INT REFERENCES users(id) ON DELETE SET NULL,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL, branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(), 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 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 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 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 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 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() { 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' }); 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))) { if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
return res.status(400).json({ error: `${key} должен быть true или false` }); 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_AI_DEFAULT_STRENGTH = 0.7;
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']); 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_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 || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.'; const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
let entryAutoChecker = null; let entryAutoChecker = null;
let photoWorker = 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() { async function getAiPrompt() {
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT); const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT);
@@ -1947,30 +2037,33 @@ function normalizeOpenAiBase(base) {
return b; return b;
} }
async function aiCorrectText(text) { async function aiComplete(systemPrompt, userText, maxTokensOverride) {
const profile = await getActiveAiProfile(); const profile = await getActiveAiProfile();
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`; let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
let model = AI_MODEL; let model = AI_MODEL;
const headers = { 'Content-Type': 'application/json' }; 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) { if (profile) {
const base = normalizeOpenAiBase(profile.base_url); const base = normalizeOpenAiBase(profile.base_url);
if (!base) throw new Error('Некорректный base_url профиля ИИ'); if (!base) throw new Error('Некорректный base_url профиля ИИ');
url = `${base}/chat/completions`; url = `${base}/chat/completions`;
model = profile.model; model = profile.model;
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`; 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 { try {
const systemPrompt = await getAiPrompt();
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers, headers,
signal: controller.signal,
body: JSON.stringify({ body: JSON.stringify({
model, model,
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: text } { role: 'user', content: userText }
], ],
temperature: 0.1, temperature: 0.1,
max_tokens: maxTokens max_tokens: maxTokens
@@ -1978,7 +2071,17 @@ async function aiCorrectText(text) {
}); });
if (!res.ok) throw new Error(`AI service error: ${res.status}`); if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json(); 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) { } catch (e) {
console.error('AI correct error:', e); console.error('AI correct error:', e);
throw e; throw e;
@@ -2339,11 +2442,24 @@ function normalizeRestoreData(data) {
lesson_date: reqDate(x.lesson_date), lesson_date: reqDate(x.lesson_date),
lesson_time: optTime(x.lesson_time), lesson_time: optTime(x.lesson_time),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX), 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), author_id: optInt(x.author_id, 0, 2147483647),
branch_id: optInt(x.branch_id, 0, 2147483647), branch_id: optInt(x.branch_id, 0, 2147483647),
created_at: optTs(x.created_at), created_at: optTs(x.created_at),
updated_at: optTs(x.updated_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 = {}; const settings = {};
for (const [k, v] of Object.entries(data.settings || {})) { for (const [k, v] of Object.entries(data.settings || {})) {
settings[reqStr(k, 100)] = reqStr(String(v), 10000); settings[reqStr(k, 100)] = reqStr(String(v), 10000);
@@ -2363,7 +2479,7 @@ function normalizeRestoreData(data) {
created_at: optTs(x.created_at), created_at: optTs(x.created_at),
finished_at: optTs(x.finished_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; const BACKUP_TTL_MS = 30 * 60 * 1000;
@@ -2397,7 +2513,7 @@ function sweepBackupStorage() {
async function buildBackupArchive() { async function buildBackupArchive() {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try { 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 groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries 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 share_links ORDER BY id'),
pool.query('SELECT * FROM photo_jobs 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_reports ORDER BY id'),
pool.query('SELECT * FROM lesson_report_versions ORDER BY id'),
]); ]);
const settings = {}; const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; }); 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)); fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
await storage.downloadAll(path.join(staging, 'uploads')); await storage.downloadAll(path.join(staging, 'uploads'));
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-'); 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 { try {
await client.query('BEGIN'); await client.query('BEGIN');
await client.query('DELETE FROM project_files'); 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 lesson_reports');
await client.query('DELETE FROM entries'); await client.query('DELETE FROM entries');
await client.query('DELETE FROM modules'); 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]); const ex = await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id]);
if (!ex.rowCount) continue; if (!ex.rowCount) continue;
await client.query( 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)', '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.author_id, x.branch_id, x.created_at, x.updated_at] [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) { for (const x of ndata.photo_jobs) {
@@ -2679,7 +2805,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[k, String(v ?? '')] [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); 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]); 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 (отчёт о занятии) --- // --- Lesson reports (отчёт о занятии) ---
const LESSON_REPORT_TEXT_MAX = 5000; const LESSON_REPORT_TEXT_MAX = 5000;
const LESSON_REPORT_LIST_TTL_MS = 30 * 1000; 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) { async function lessonReportGroup(user, groupId) {
const gid = Number(groupId); const gid = Number(groupId);
@@ -3676,6 +3804,22 @@ function parseLessonReportTime(v) {
return s.length === 5 ? s + ':00' : s; 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) => { app.get('/api/lesson-reports', requireAuth, async (req, res) => {
const { date_from, date_to, group_id, search, limit, offset } = req.query; const { date_from, date_to, group_id, search, limit, offset } = req.query;
const conditions = []; 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 rows = await cacheWrap(key, LESSON_REPORT_LIST_TTL_MS, async () => {
const qparams = params.slice(); const qparams = params.slice();
let q = `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.text, lr.author_id, 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, lr.created_at, lr.updated_at, g.name AS group_name,
u.name AS author_name, u.username AS author_username u.name AS author_name, u.username AS author_username
${from} ${from}
@@ -3718,7 +3863,7 @@ app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => {
}); });
app.post('/api/lesson-reports', 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); const grp = await lessonReportGroup(req.user, group_id);
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error }); if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
const date = parseLessonReportDate(lesson_date); const date = parseLessonReportDate(lesson_date);
@@ -3735,28 +3880,38 @@ app.post('/api/lesson-reports', requireAuth, async (req, res) => {
if (existing.length) { if (existing.length) {
return res.status(409).json({ error: 'За эту группу и дату отчёт уже есть — откройте его для редактирования', id: existing[0].id }); 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( const { rows } = await pool.query(
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, author_id, branch_id) `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, $6) RETURNING *`, 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, req.user.id || null, grp.group.branch_id || null] [
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(); invalidateLessonReports();
if (aiWanted) wakeLessonAiWorker();
await pushNotification({ await pushNotification({
type: 'lesson.report', type: 'lesson.report',
title: `Отчёт о занятии: ${grp.group.name}`, title: `Отчёт о занятии: ${grp.group.name}`,
body: body.slice(0, 300), body: body.slice(0, 300),
link: 'lessons.html', 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, 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) => { app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
const { report, error, code } = await lessonReportById(req.user, req.params.id); const { report, error, code } = await lessonReportById(req.user, req.params.id);
if (!report) return res.status(code || 404).json({ error }); 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); const curDate = String(report.lesson_date).slice(0, 10);
let date = curDate; let date = curDate;
if (lesson_date !== undefined && lesson_date !== null && lesson_date !== '') { 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: 'За эту группу и дату уже есть другой отчёт' }); 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( const { rows } = await pool.query(
`UPDATE lesson_reports SET `UPDATE lesson_reports SET
lesson_date = $1::date, lesson_date = $1::date,
lesson_time = $2, lesson_time = $2,
text = COALESCE($3, text), 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() updated_at = now()
WHERE id = $4 RETURNING *`, WHERE id = $5 RETURNING *`,
[date, time === undefined ? report.lesson_time : time, body, report.id] [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(); invalidateLessonReports();
res.json(rows[0]); res.json(rows[0]);
}); });
@@ -6727,4 +6963,15 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
}); });
photoWorker.start(); photoWorker.start();
console.log('Photo enhance worker started'); 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 }; 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 };