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:
@@ -2,6 +2,8 @@
|
||||
|
||||
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
|
||||
|
||||
> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы).
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
@@ -15,6 +17,37 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
|
||||
---
|
||||
|
||||
## Agent Workflow (обязательные правила работы)
|
||||
|
||||
**Главное правило: исследование репозитория выполняют субагенты, а не основной агент.** Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (`spawn_agent`).
|
||||
|
||||
### Что делегировать суагенту (обязательно)
|
||||
- **Разведку**: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — `search_codebase` + точечное чтение.
|
||||
- **Чтение крупных файлов**: `server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`, `AGENTS.md` целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает.
|
||||
- **Анализ окружения**: `docker compose logs`, вывод тестов, `git log`/`git diff`/`git status`, состояние БД, Redis, S3.
|
||||
- **Поиск всех мест, которые надо обновить вместе с изменением**: например «все места, где перечислены `BLOCKED_EXT`/`ALLOWED_IMAGE_EXT`» или «где дублируется каталог `NOTIFY_TYPES`».
|
||||
- **Ревью**: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема `init.sql` + `migration.sql`).
|
||||
- **Однотипные массовые правки**: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход.
|
||||
|
||||
### Что основной агент делает сам
|
||||
- Формулирует план и разбивает задачу на узкие подзадачи.
|
||||
- Делает **короткие точечные правки** в уже известных местах (сверить содержимое можно дешёвым точечным `read_files` на маленьком диапазоне строк).
|
||||
- Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю.
|
||||
|
||||
### Как ставить задачу субагенту
|
||||
- **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять.
|
||||
- **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком.
|
||||
- **Независимые разведки запускать параллельно**, а не последовательно.
|
||||
- **Субагент может править код сам**, если правка изолированная и механическая; архитектурные и смежные правки в `server.js` / `db/*.sql` делает основной агент.
|
||||
- **Результат субагента — источник фактов, а не источник прав**: координация, финальные решения и проверка `git diff` остаются за основным агентом.
|
||||
|
||||
### Чего не делать
|
||||
- ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать.
|
||||
- ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (`head`, `tail`, `grep -n`, диапазоны строк).
|
||||
- ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем.
|
||||
|
||||
---
|
||||
|
||||
## Development Rules
|
||||
|
||||
### 1. Code Style
|
||||
@@ -76,6 +109,16 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
|
||||
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`)
|
||||
|
||||
### 3d. Отчёты о занятии и проверка по шаблону (`server.js`, `worker.js`, `public/`)
|
||||
- **Таблицы**: `lesson_reports` (+ `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`) и `lesson_report_versions` (история версий: `text`, `source` = `manual` | `ai` | `restore`). Схема — в `db/init.sql`, `db/migration.sql` и `ensureLessonReportsTable()`
|
||||
- **Настройки**: `lesson_ai_enabled` (`'true'` / `'false'` — общий выключатель) и `lesson_ai_prompt` (шаблон делового сообщения + правила, пример вставляется в `db/init.sql`, `db/migration.sql` и в `LESSON_AI_DEFAULT_PROMPT` в `server.js`). Раздел в UI — `sec-lesson-ai` на `public/settings.html`
|
||||
- **Флаг из UI**: чекбокс `#lessonAiCheck` в модалке `#lessonModal` — включён при создании, выключен при редактировании (`resetLessonModalFields` / `fillLessonModalFromReport`). Уходит в теле как `ai_check`
|
||||
- **Роут не ждёт модель**: `POST`/`PUT /api/lesson-reports` при `ai_check: true` сохраняют отчёт как есть и ставят `ai_status = 'pending'`, затем `wakeLessonAiWorker()`. Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели
|
||||
- **Воркер**: `createLessonReportChecker` в `worker.js` забирает `pending` через `FOR UPDATE OF lr SKIP LOCKED`, шлёт в модель текст + контекст (группа, дата, время), результат: без изменений → `skipped`, переписан → `done` (новый текст в `text` и `text_ai`), сбой → до 3 попыток, затем `error`
|
||||
- **Доставка результата**: `onDone` в `server.js` пишет версию (`saveLessonReportVersion`), аудит с diff (`lesson_report.ai.format`), уведомление `lesson.ai.formatted` и SSE `lesson_report_status` на `EVENTS_CHANNEL`
|
||||
- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт
|
||||
- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js`
|
||||
|
||||
### 4. API Patterns
|
||||
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
||||
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
||||
@@ -118,6 +161,8 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
|
||||
## Common Tasks
|
||||
|
||||
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент.
|
||||
|
||||
### Add a new API endpoint
|
||||
1. Add route in `server.js` (group with related routes)
|
||||
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
|
||||
@@ -243,7 +288,7 @@ guards against.
|
||||
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
|
||||
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
|
||||
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
|
||||
| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker |
|
||||
| `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance |
|
||||
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
||||
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
|
||||
@@ -278,6 +323,10 @@ guards against.
|
||||
- ❌ Expose DB port (5432) outside docker network
|
||||
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
||||
- ❌ Add comments to code (this file excepted)
|
||||
- ❌ Read/search the repo in the main agent when the work can be delegated to a subagent
|
||||
- ❌ Load whole files (`server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`) or raw command output into the main context — delegate and filter (`grep -n`, `head`, line ranges)
|
||||
- ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its `systemPrompt` — it does not inherit the main context
|
||||
- ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (`git diff`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- **Воспитанники** — справочник с привязкой к группам
|
||||
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
||||
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
||||
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
|
||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
||||
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
||||
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
|
||||
|
||||
@@ -210,6 +210,49 @@ async function main() {
|
||||
body: enhanceAi.data,
|
||||
});
|
||||
|
||||
// Отчёты о занятии: контракт проверки по шаблону и истории версий.
|
||||
// Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь
|
||||
// и то, что при ai_check:false текст остаётся нетронутым.
|
||||
const lrGroups = await api('/api/groups', { token });
|
||||
const gid = lrGroups.data[0] && lrGroups.data[0].id;
|
||||
if (gid) {
|
||||
const date = '2019-05-17';
|
||||
await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token })
|
||||
.then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' })));
|
||||
|
||||
const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.';
|
||||
const plain = await api('/api/lesson-reports', {
|
||||
token, method: 'POST',
|
||||
body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false },
|
||||
});
|
||||
ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null',
|
||||
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
|
||||
{ status: plain.status, ai_status: plain.data.ai_status });
|
||||
|
||||
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
|
||||
ok('lesson-report: история версий содержит исходный текст',
|
||||
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
|
||||
&& versions.data.items.some(v => v.text === plainText && v.source === 'manual'),
|
||||
{ status: versions.status, items: (versions.data.items || []).length });
|
||||
|
||||
const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' });
|
||||
ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status);
|
||||
|
||||
const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' });
|
||||
ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status);
|
||||
|
||||
const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' });
|
||||
ok('lesson-report: удаление', del.status === 200, del.status);
|
||||
} else {
|
||||
ok('lesson-report: есть группа для проверки', false, 'no groups');
|
||||
}
|
||||
|
||||
const lessonNotifyMeta = await api('/api/notifications/meta', { token });
|
||||
// /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы
|
||||
ok('notifications: настройка lesson.ai.formatted заведена',
|
||||
(lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'),
|
||||
(lessonNotifyMeta.data.types || []).map(t => t.key));
|
||||
|
||||
const logout = await api('/api/auth/logout', { token, method: 'POST' });
|
||||
ok('logout', logout.status === 200, logout.status);
|
||||
const afterLogout = await api('/api/auth/me', { token });
|
||||
|
||||
+41
@@ -227,6 +227,11 @@ CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||
lesson_date DATE NOT NULL,
|
||||
lesson_time TIME,
|
||||
text TEXT NOT NULL,
|
||||
text_original TEXT,
|
||||
text_ai TEXT,
|
||||
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
@@ -236,6 +241,17 @@ CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lesson_report_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
@@ -324,3 +340,28 @@ INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор деловых отчётов образовательного центра.
|
||||
|
||||
Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.
|
||||
|
||||
ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:
|
||||
Отчёт о проведённом занятии
|
||||
Дата: <дата занятия>
|
||||
Группа: <название группы>
|
||||
Темы: <перечень тем>
|
||||
Практика: <задания>
|
||||
Домашнее задание: <что задано>
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.
|
||||
2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.
|
||||
3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.
|
||||
4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.
|
||||
5. Не добавляй приветствия, подписи и какие-либо пояснения.
|
||||
6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
@@ -320,3 +320,42 @@ CREATE TABLE IF NOT EXISTS lesson_reports (
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
|
||||
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT;
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT;
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lesson_report_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор деловых отчётов образовательного центра.
|
||||
|
||||
Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.
|
||||
|
||||
ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:
|
||||
Отчёт о проведённом занятии
|
||||
Дата: <дата занятия>
|
||||
Группа: <название группы>
|
||||
Темы: <перечень тем>
|
||||
Практика: <задания>
|
||||
Домашнее задание: <что задано>
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.
|
||||
2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.
|
||||
3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.
|
||||
4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.
|
||||
5. Не добавляй приветствия, подписи и какие-либо пояснения.
|
||||
6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
@@ -662,6 +662,20 @@ body[data-page=settings] .page-head{align-items:flex-end}
|
||||
.lesson-modal .lesson-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.lesson-modal .hint{font-size:.78rem;color:var(--muted);text-align:right}
|
||||
.lesson-modal textarea{min-height:130px}
|
||||
.lesson-modal .lesson-ai-toggle{background:var(--bg);border:1px solid var(--border);border-radius:10px}
|
||||
.lesson-ai-status{font-size:.78rem;color:var(--muted);line-height:1.45;margin-top:-4px}
|
||||
.lesson-ai-status.err{color:#ef4444}
|
||||
.lesson-versions{display:flex;flex-direction:column;gap:8px;max-height:56vh;overflow-y:auto;margin:4px 0}
|
||||
.lesson-version{border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;background:var(--bg)}
|
||||
.lesson-version.current{border-color:var(--accent);background:rgba(37,99,235,.06)}
|
||||
.lesson-version-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.lesson-version-label{font-size:.78rem;font-weight:600}
|
||||
.lesson-version-time{font-size:.74rem;color:var(--muted);margin-left:auto}
|
||||
.lesson-version-text{font-size:.82rem;line-height:1.5;white-space:pre-wrap;word-break:break-word;margin-top:6px}
|
||||
.lesson-version-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}
|
||||
.lesson-version-author{font-size:.74rem;color:var(--muted)}
|
||||
.lesson-version-foot .btn-link{margin-left:auto;padding:4px 10px;font-size:.75rem}
|
||||
.lesson-item .ai-badge{margin-left:0}
|
||||
@media(max-width:560px){
|
||||
.lesson-modal .lesson-row{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
+154
-2
@@ -178,6 +178,7 @@ const NOTIFY_ICONS = {
|
||||
'backup.create': 'download',
|
||||
'system.test': 'send',
|
||||
'lesson.report': 'notebook-pen',
|
||||
'lesson.ai.formatted': 'file-check',
|
||||
};
|
||||
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
|
||||
|
||||
@@ -540,7 +541,14 @@ function ensureLessonModal() {
|
||||
placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea>
|
||||
<span class="hint" id="lessonHint">0 / 5000</span>
|
||||
</div>
|
||||
<label class="toggle lesson-ai-toggle" id="lessonAiRow">
|
||||
<input type="checkbox" id="lessonAiCheck" checked>
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Проверить по шаблону делового сообщения</b><small>ИИ сверит текст с шаблоном из настроек и, если не совпадёт, оформит его как деловое сообщение</small></span>
|
||||
</label>
|
||||
<div class="lesson-ai-status" id="lessonAiStatus" hidden></div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="lessonVersionsBtn" hidden>История версий</button>
|
||||
<button type="button" class="btn-primary ghost" id="lessonCancel">Отмена</button>
|
||||
<button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button>
|
||||
</div>
|
||||
@@ -556,6 +564,10 @@ function ensureLessonModal() {
|
||||
textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; });
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); });
|
||||
modal.querySelector('#lessonCancel').addEventListener('click', closeLessonModal);
|
||||
modal.querySelector('#lessonVersionsBtn').addEventListener('click', () => {
|
||||
const id = modal.querySelector('#lessonId').value;
|
||||
if (id) openLessonVersions(id);
|
||||
});
|
||||
groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
|
||||
modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e));
|
||||
return modal;
|
||||
@@ -570,6 +582,7 @@ async function submitLessonModal(e) {
|
||||
const timeEl = modal.querySelector('#lessonTime');
|
||||
const textEl = modal.querySelector('#lessonText');
|
||||
const submitBtn = modal.querySelector('#lessonSubmit');
|
||||
const aiCheckEl = modal.querySelector('#lessonAiCheck');
|
||||
const groupId = groupSel.value;
|
||||
const date = dateEl.value;
|
||||
const text = textEl.value.trim();
|
||||
@@ -577,12 +590,13 @@ async function submitLessonModal(e) {
|
||||
if (!date) { showToast('Укажите дату занятия'); return; }
|
||||
if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; }
|
||||
const id = modal.querySelector('#lessonId').value;
|
||||
const aiCheck = !!aiCheckEl && aiCheckEl.checked;
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text }),
|
||||
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text, ai_check: aiCheck }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
@@ -596,7 +610,13 @@ async function submitLessonModal(e) {
|
||||
return;
|
||||
}
|
||||
closeLessonModal();
|
||||
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
|
||||
if (data.ai_status === 'pending' || data.ai_status === 'processing') {
|
||||
showToast('Отчёт сохранён — ИИ проверяет текст по шаблону');
|
||||
} else if (data.ai_status === 'error') {
|
||||
showToast(`Отчёт сохранён, проверка по шаблону не удалась: ${data.ai_error || 'ошибка ИИ'}`);
|
||||
} else {
|
||||
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
|
||||
}
|
||||
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
|
||||
} catch {
|
||||
showToast('Ошибка сети');
|
||||
@@ -633,10 +653,38 @@ function fillLessonModalFromReport(modal, r) {
|
||||
textEl.value = r.text || '';
|
||||
textEl.dispatchEvent(new Event('input'));
|
||||
}
|
||||
const aiCheck = modal.querySelector('#lessonAiCheck');
|
||||
if (aiCheck) aiCheck.checked = false;
|
||||
updateLessonAiRow(modal, r);
|
||||
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
|
||||
modal.querySelector('#lessonSubmit').textContent = 'Обновить';
|
||||
}
|
||||
|
||||
function lessonAiStatusText(r) {
|
||||
if (!r || !r.ai_status || r.ai_status === 'none') return '';
|
||||
if (r.ai_status === 'pending') return 'ИИ проверяет текст по шаблону…';
|
||||
if (r.ai_status === 'processing') return 'ИИ оформляет текст по шаблону…';
|
||||
if (r.ai_status === 'done' && r.text_ai) return 'ИИ оформил текст по шаблону';
|
||||
if (r.ai_status === 'skipped') return 'Текст уже соответствует шаблону';
|
||||
if (r.ai_status === 'reverted') return 'Возвращена исходная версия текста';
|
||||
if (r.ai_status === 'error') return `Проверка по шаблону не удалась: ${r.ai_error || 'ошибка ИИ'}`;
|
||||
return `Статус ИИ: ${r.ai_status}`;
|
||||
}
|
||||
|
||||
function updateLessonAiRow(modal, r) {
|
||||
const row = modal.querySelector('#lessonAiRow');
|
||||
const status = modal.querySelector('#lessonAiStatus');
|
||||
const versionsBtn = modal.querySelector('#lessonVersionsBtn');
|
||||
if (row) row.hidden = false;
|
||||
const text = lessonAiStatusText(r);
|
||||
if (status) {
|
||||
status.textContent = text;
|
||||
status.hidden = !text;
|
||||
status.classList.toggle('err', !!r && r.ai_status === 'error');
|
||||
}
|
||||
if (versionsBtn) versionsBtn.hidden = !modal.querySelector('#lessonId').value;
|
||||
}
|
||||
|
||||
function resetLessonModalFields(modal) {
|
||||
modal.querySelector('#lessonId').value = '';
|
||||
modal.querySelector('#lessonDate').value = lessonTodayIso();
|
||||
@@ -644,10 +692,114 @@ function resetLessonModalFields(modal) {
|
||||
const textEl = modal.querySelector('#lessonText');
|
||||
textEl.value = '';
|
||||
textEl.dispatchEvent(new Event('input'));
|
||||
const aiCheck = modal.querySelector('#lessonAiCheck');
|
||||
if (aiCheck) aiCheck.checked = true;
|
||||
updateLessonAiRow(modal, null);
|
||||
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
|
||||
modal.querySelector('#lessonSubmit').textContent = 'Сохранить';
|
||||
}
|
||||
|
||||
const LESSON_VERSION_LABELS = {
|
||||
manual: 'Тьютор',
|
||||
ai: 'ИИ по шаблону',
|
||||
restore: 'Восстановлено'
|
||||
};
|
||||
|
||||
let currentLessonVersionId = '';
|
||||
|
||||
function ensureLessonVersionsModal() {
|
||||
let modal = document.getElementById('lessonVersionsModal');
|
||||
if (modal) return modal;
|
||||
modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'lessonVersionsModal';
|
||||
modal.innerHTML = `
|
||||
<div class="edit-modal lesson-modal">
|
||||
<h3>История версий отчёта</h3>
|
||||
<div class="lesson-versions" id="lessonVersionsList"><div class="empty">Загрузка…</div></div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="lessonVersionsClose">Закрыть</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeLessonVersions(); });
|
||||
modal.querySelector('#lessonVersionsClose').addEventListener('click', closeLessonVersions);
|
||||
return modal;
|
||||
}
|
||||
|
||||
function closeLessonVersions() {
|
||||
const modal = document.getElementById('lessonVersionsModal');
|
||||
if (modal) modal.classList.remove('open');
|
||||
}
|
||||
|
||||
function lessonVersionItemHtml(v, index) {
|
||||
const label = LESSON_VERSION_LABELS[v.source] || v.source || 'Версия';
|
||||
const who = v.author_name || v.author_username || '';
|
||||
return `
|
||||
<div class="lesson-version${index === 0 ? ' current' : ''}">
|
||||
<div class="lesson-version-head">
|
||||
<span class="lesson-version-label">${esc(label)}</span>
|
||||
<span class="lesson-version-time">${esc(notifyTime(v.created_at))}</span>
|
||||
</div>
|
||||
<div class="lesson-version-text">${esc(v.text || '')}</div>
|
||||
<div class="lesson-version-foot">
|
||||
${who ? `<span class="lesson-version-author">${esc(who)}</span>` : ''}
|
||||
${index === 0 ? '<span class="lesson-badge">текущая</span>' : ''}
|
||||
${index === 0 ? '' : `<button type="button" class="btn-link" data-restore="${v.id}" data-report="${v.lesson_report_id || currentLessonVersionId || ''}">Восстановить</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function openLessonVersions(id) {
|
||||
currentLessonVersionId = String(id || '');
|
||||
const modal = ensureLessonVersionsModal();
|
||||
const list = modal.querySelector('#lessonVersionsList');
|
||||
list.innerHTML = '<div class="empty">Загрузка…</div>';
|
||||
modal.classList.add('open');
|
||||
renderIcons();
|
||||
try {
|
||||
const res = await fetch(`${API}/api/lesson-reports/${id}/versions`, { headers: hdr() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { list.innerHTML = `<div class="empty">${esc(data.error || 'Не удалось загрузить историю')}</div>`; return; }
|
||||
const items = data.items || [];
|
||||
if (!items.length) { list.innerHTML = '<div class="empty">Версий пока нет</div>'; return; }
|
||||
list.innerHTML = items.map(lessonVersionItemHtml).join('');
|
||||
renderIcons();
|
||||
} catch {
|
||||
list.innerHTML = '<div class="empty">Ошибка сети</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreLessonVersion(id, versionId, btn) {
|
||||
if (!confirm('Восстановить эту версию отчёта?')) return;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/lesson-reports/${id}/versions/${versionId}/restore`, { method: 'POST', headers: hdr() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { showToast(data.error || 'Не удалось восстановить версию'); return; }
|
||||
closeLessonVersions();
|
||||
showToast('Версия отчёта восстановлена');
|
||||
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
|
||||
} catch {
|
||||
showToast('Ошибка сети');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-restore]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
let id = btn.dataset.report || currentLessonVersionId;
|
||||
if (!id) {
|
||||
const lessonModal = document.getElementById('lessonModal');
|
||||
id = (lessonModal && lessonModal.querySelector('#lessonId').value) || '';
|
||||
}
|
||||
if (!id) return;
|
||||
restoreLessonVersion(id, btn.dataset.restore, btn);
|
||||
});
|
||||
|
||||
async function openLessonModal(opts = {}) {
|
||||
const modal = ensureLessonModal();
|
||||
const groupSel = modal.querySelector('#lessonGroup');
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ nav a:hover{color:var(--text)}
|
||||
|
||||
<div class="field">
|
||||
<label>Что сделал</label>
|
||||
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||
<textarea id="descInput" maxlength="300" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||
</div>
|
||||
|
||||
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||
|
||||
+9
-2
@@ -82,6 +82,13 @@ const ACTION_LABELS = {
|
||||
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
||||
'export.student': 'Экспорт данных ученика',
|
||||
'export.group_files': 'Экспорт файлов группы (ZIP)',
|
||||
'lesson_report.create': 'Создан отчёт о занятии',
|
||||
'lesson_report.update': 'Изменён отчёт о занятии',
|
||||
'lesson_report.delete': 'Удалён отчёт о занятии',
|
||||
'lesson_report.ai.format': 'ИИ оформил отчёт по шаблону',
|
||||
'lesson_report.ai.error': 'Ошибка проверки отчёта по шаблону',
|
||||
'lesson_report.ai.revert': 'Возвращён оригинал отчёта',
|
||||
'lesson_report.version.restore': 'Восстановлена версия отчёта',
|
||||
};
|
||||
|
||||
const AUDIT_PAGE_SIZE = 50;
|
||||
@@ -202,7 +209,7 @@ function renderTargetDetails(target) {
|
||||
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
|
||||
}
|
||||
|
||||
function renderPager(container, page, totalPages, onPageChange) {
|
||||
function renderAuditPager(container, page, totalPages, onPageChange) {
|
||||
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
@@ -251,7 +258,7 @@ async function loadAudit() {
|
||||
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
|
||||
renderAuditPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
|
||||
}
|
||||
|
||||
function goAuditPage(p) {
|
||||
|
||||
@@ -25,16 +25,31 @@ function lessonAuthorText(r) {
|
||||
return upd ? `${name} · изменён ${notifyTime(r.updated_at)}` : name;
|
||||
}
|
||||
|
||||
function lessonAiBadgeHtml(r) {
|
||||
const st = r.ai_status;
|
||||
if (!st || st === 'none') return '';
|
||||
if (st === 'pending' || st === 'processing') return `<span class="ai-badge ai-badge-ic ai-badge-processing" title="ИИ проверяет текст по шаблону"><i data-lucide="loader"></i></span>`;
|
||||
if (st === 'done' && r.text_ai) return '<span class="ai-badge ai-badge-done" title="ИИ оформил текст по шаблону">ИИ</span>';
|
||||
if (st === 'skipped') return '<span class="ai-badge ai-badge-skipped" title="Текст уже соответствует шаблону">По шаблону</span>';
|
||||
if (st === 'reverted') return '<span class="ai-badge ai-badge-reverted" title="Возвращена исходная версия текста">Оригинал</span>';
|
||||
if (st === 'error') return `<span class="ai-badge ai-badge-error" title="Проверка по шаблону не удалась: ${esc(r.ai_error || 'ошибка ИИ')}">Ошибка ИИ</span>`;
|
||||
return `<span class="ai-badge ai-badge-ic ai-badge-${esc(st)}" title="Статус ИИ: ${esc(st)}">${esc(st)}</span>`;
|
||||
}
|
||||
|
||||
function lessonItemHtml(r) {
|
||||
const isToday = String(r.lesson_date).slice(0, 10) === lessonTodayIso();
|
||||
const author = lessonAuthorText(r);
|
||||
const canRevert = !!r.text_original && r.text_original !== r.text;
|
||||
return `
|
||||
<div class="lesson-item" data-lesson="${r.id}">
|
||||
<div class="lesson-item-head">
|
||||
<span class="lesson-group">${esc(r.group_name || '')}</span>
|
||||
<span class="lesson-when"><i data-lucide="clock-3"></i>${esc(lessonWhenText(r))}</span>
|
||||
${isToday ? '<span class="lesson-badge today">сегодня</span>' : ''}
|
||||
${lessonAiBadgeHtml(r)}
|
||||
<div class="lesson-actions">
|
||||
<button type="button" class="btn-link" data-versions="${r.id}">История</button>
|
||||
${canRevert ? `<button type="button" class="btn-link" data-revert="${r.id}" title="Вернуть исходный текст тьютора">↩ Оригинал</button>` : ''}
|
||||
<button type="button" class="btn-link" data-edit="${r.id}">Редактировать</button>
|
||||
${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''}
|
||||
</div>
|
||||
@@ -108,6 +123,15 @@ async function deleteLesson(id) {
|
||||
loadLessons();
|
||||
}
|
||||
|
||||
async function revertLessonAi(id) {
|
||||
if (!confirm('Вернуть исходный текст отчёта (до оформления по шаблону)?')) return;
|
||||
const res = await fetch(`${API}/api/lesson-reports/${id}/ai/revert`, { method: 'POST', headers: hdr() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { showToast(data.error || 'Не удалось вернуть оригинал'); return; }
|
||||
showToast('Возвращён исходный текст отчёта');
|
||||
loadLessons();
|
||||
}
|
||||
|
||||
function onLessonReportSaved() {
|
||||
loadLessons();
|
||||
}
|
||||
@@ -115,6 +139,10 @@ function onLessonReportSaved() {
|
||||
document.getElementById('lessonsList').addEventListener('click', e => {
|
||||
const edit = e.target.closest('[data-edit]');
|
||||
if (edit) { openLessonModal({ id: edit.dataset.edit }); return; }
|
||||
const versions = e.target.closest('[data-versions]');
|
||||
if (versions) { openLessonVersions(versions.dataset.versions); return; }
|
||||
const revert = e.target.closest('[data-revert]');
|
||||
if (revert) { revertLessonAi(revert.dataset.revert); return; }
|
||||
const del = e.target.closest('[data-del]');
|
||||
if (del) { deleteLesson(del.dataset.del); }
|
||||
});
|
||||
@@ -131,6 +159,26 @@ document.getElementById('searchInput').addEventListener('input', () => {
|
||||
searchTimer = setTimeout(() => { page = 1; loadLessons(); }, 350);
|
||||
});
|
||||
|
||||
let lessonLiveSource = null;
|
||||
|
||||
function connectLessonLive() {
|
||||
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
|
||||
const connect = () => {
|
||||
const t = sessionStorage.getItem('authToken');
|
||||
if (!t) return;
|
||||
lessonLiveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
|
||||
lessonLiveSource.addEventListener('lesson_report_status', () => loadLessons());
|
||||
lessonLiveSource.onerror = () => {
|
||||
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
|
||||
setTimeout(connect, 5000);
|
||||
};
|
||||
};
|
||||
connect();
|
||||
window.addEventListener('beforeunload', () => { if (lessonLiveSource) lessonLiveSource.close(); });
|
||||
}
|
||||
|
||||
connectLessonLive();
|
||||
|
||||
(async () => {
|
||||
if (!(await checkAuth())) return;
|
||||
buildSidebar(document.body.dataset.page);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||
const NOTIFY_IDS = [];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
'honeypot': 'Антиспам-поле',
|
||||
@@ -206,6 +206,8 @@ async function loadSettings() {
|
||||
document.getElementById('footerRight').value = s.footer_right || '';
|
||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
|
||||
document.getElementById('lessonAiEnabled').checked = s.lesson_ai_enabled !== 'false';
|
||||
document.getElementById('lessonAiPrompt').value = s.lesson_ai_prompt || '';
|
||||
document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true';
|
||||
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
|
||||
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
|
||||
@@ -807,6 +809,8 @@ async function saveSettings() {
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
ai_prompt: document.getElementById('aiPrompt').value.trim(),
|
||||
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
|
||||
lesson_ai_enabled: document.getElementById('lessonAiEnabled').checked ? 'true' : 'false',
|
||||
lesson_ai_prompt: document.getElementById('lessonAiPrompt').value.trim(),
|
||||
share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
|
||||
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
|
||||
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<a href="#sec-photo-ai" data-nav="sec-photo-ai"><i data-lucide="sparkles"></i> Фото-ИИ</a>
|
||||
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
|
||||
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||
<a href="#sec-lesson-ai" data-nav="sec-lesson-ai"><i data-lucide="notebook-pen"></i> Шаблон отчёта</a>
|
||||
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
|
||||
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
||||
@@ -165,6 +166,24 @@
|
||||
<div class="ai-queue" id="aiQueueInfo"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-lesson-ai">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="notebook-pen"></i></div>
|
||||
<h3>Шаблон отчёта о занятии</h3>
|
||||
</div>
|
||||
<p class="hint">Инструкция для нейросети: она сверяет текст отчёта тьютора с шаблоном делового сообщения. Если текст уже соответствует шаблону — остаётся без изменений, иначе приводится к деловому виду. Оригинал тьютора всегда сохраняется, результат можно посмотреть в истории версий.</p>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="lessonAiEnabled">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Проверять отчёты по шаблону</b><small>Применяется, когда тьютор включил галочку в окне отчёта о занятии</small></span>
|
||||
</label>
|
||||
<div class="settings-field">
|
||||
<label for="lessonAiPrompt">Шаблон и правила</label>
|
||||
<textarea id="lessonAiPrompt" class="settings-input mono" rows="12" spellcheck="false"></textarea>
|
||||
<span class="hint">Пусто — будет подставлен шаблон по умолчанию</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="boxes"></i></div>
|
||||
|
||||
@@ -5,7 +5,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const helmet = require('helmet');
|
||||
const bcrypt = require('bcrypt');
|
||||
const heicConvert = require('heic-convert');
|
||||
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
|
||||
const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker } = require('./worker');
|
||||
const { createZipWriter, renderStudentReport } = require('./student-report');
|
||||
|
||||
const { createStorage, mimeFor } = require('./storage');
|
||||
@@ -101,6 +101,7 @@ function invalidateSessions() { cacheDrop('session:'); }
|
||||
const EVENTS_CHANNEL = 'whatido:events';
|
||||
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
|
||||
const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo';
|
||||
const LESSON_AI_WAKE_CHANNEL = 'whatido:wake:lesson-ai';
|
||||
|
||||
function createWorkerBus(channel) {
|
||||
return {
|
||||
@@ -123,6 +124,17 @@ function writeFrame(event, data) {
|
||||
}
|
||||
|
||||
function dispatchEvent(payload) {
|
||||
if (payload && payload.type === 'lesson_report_status') {
|
||||
writeFrame('lesson_report_status', {
|
||||
id: payload.id,
|
||||
ai_status: payload.status,
|
||||
ai_error: payload.error || null,
|
||||
text: payload.text === undefined ? null : payload.text,
|
||||
text_ai: payload.text_ai === undefined ? null : payload.text_ai,
|
||||
ts: Date.now()
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (payload && payload.type === 'ai_status') {
|
||||
writeFrame('ai_status', {
|
||||
id: payload.id,
|
||||
@@ -143,6 +155,11 @@ function broadcastEntryChanged() {
|
||||
.catch(err => console.error('Publish failed:', err.message));
|
||||
}
|
||||
|
||||
function broadcastLessonChanged() {
|
||||
cache.publish(EVENTS_CHANNEL, { type: 'lessons_changed' })
|
||||
.catch(err => console.error('Publish failed:', err.message));
|
||||
}
|
||||
|
||||
function broadcastAiStatus(payload) {
|
||||
cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload })
|
||||
.catch(err => console.error('Publish failed:', err.message));
|
||||
@@ -194,6 +211,7 @@ const NOTIFY_TYPES = {
|
||||
'backup.create': { label: 'Создан архив бэкапа', hint: 'Архив данных скачан из админ-панели', icon: 'download', level: 'info', enabled: false, admin: true },
|
||||
'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true },
|
||||
'lesson.report': { label: 'Отчёт о занятии', hint: 'Тьютор сохранил отчёт о том, что прошли на занятии', icon: 'notebook-pen', level: 'info', enabled: true, admin: false },
|
||||
'lesson.ai.formatted': { label: 'Отчёт оформлен по шаблону', hint: 'ИИ привёл текст отчёта о занятии к деловому виду', icon: 'file-check', level: 'info', enabled: false, admin: false },
|
||||
};
|
||||
|
||||
function notifySettingKey(type) {
|
||||
@@ -1236,6 +1254,11 @@ async function ensureLessonReportsTable() {
|
||||
lesson_date DATE NOT NULL,
|
||||
lesson_time TIME,
|
||||
text TEXT NOT NULL,
|
||||
text_original TEXT,
|
||||
text_ai TEXT,
|
||||
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
@@ -1245,8 +1268,27 @@ async function ensureLessonReportsTable() {
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL');
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS lesson_time TIME');
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()');
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT');
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT');
|
||||
await pool.query("ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'");
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ');
|
||||
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT');
|
||||
await pool.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date)');
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC)');
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS lesson_report_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC)');
|
||||
await pool.query("INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING");
|
||||
await pool.query(
|
||||
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING',
|
||||
['lesson_ai_prompt', LESSON_AI_DEFAULT_PROMPT]
|
||||
);
|
||||
}
|
||||
|
||||
async function ensurePhotoOriginalColumn() {
|
||||
@@ -1786,6 +1828,12 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'notify_retention_days должен быть целым числом от 1 до 365' });
|
||||
}
|
||||
}
|
||||
if (key === 'lesson_ai_enabled' && !['true', 'false'].includes(String(value))) {
|
||||
return res.status(400).json({ error: 'lesson_ai_enabled должен быть true или false' });
|
||||
}
|
||||
if (key === 'lesson_ai_prompt' && String(value).length > 8000) {
|
||||
return res.status(400).json({ error: 'lesson_ai_prompt длиннее 8000 символов' });
|
||||
}
|
||||
if (key.startsWith('notify_') && key !== 'notify_retention_days' && !['true', 'false'].includes(String(value))) {
|
||||
return res.status(400).json({ error: `${key} должен быть true или false` });
|
||||
}
|
||||
@@ -1914,9 +1962,51 @@ const PHOTO_AI_DEVICE_PREFS = ['auto', 'cuda', 'cpu'];
|
||||
const PHOTO_AI_DEFAULT_STRENGTH = 0.7;
|
||||
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
|
||||
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||
const AI_CALL_TIMEOUT_MS = Math.max(5000, parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10) || 120000);
|
||||
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||
let entryAutoChecker = null;
|
||||
let photoWorker = null;
|
||||
let lessonAiChecker = null;
|
||||
|
||||
function wakeLessonAiWorker() {
|
||||
if (lessonAiChecker) lessonAiChecker.notify();
|
||||
}
|
||||
|
||||
async function onLessonAiDone({ row, status, text, aiText, error }) {
|
||||
invalidateLessonReports();
|
||||
cache.publish(EVENTS_CHANNEL, {
|
||||
type: 'lesson_report_status',
|
||||
id: row.id,
|
||||
status,
|
||||
error: error || null,
|
||||
text,
|
||||
text_ai: aiText || null,
|
||||
}).catch(err => console.error('Publish failed:', err.message));
|
||||
const original = String(row.text_original || row.text || '').trim();
|
||||
if (status === 'done' && aiText) {
|
||||
const d = textDiff(original, text || '');
|
||||
if (logAudit) {
|
||||
await logAudit(null, 'lesson_report.ai.format', {
|
||||
id: row.id,
|
||||
source: 'ai',
|
||||
changed: d.changed,
|
||||
fields: ['text'],
|
||||
changes: d.changed
|
||||
? [{ field: 'description', label: 'Текст отчёта', stats: d.stats, diff: d.segments, truncated: d.truncated }]
|
||||
: []
|
||||
});
|
||||
}
|
||||
await saveLessonReportVersion(row.id, text, 'ai', row.author_id || null);
|
||||
await pushNotification({
|
||||
type: 'lesson.ai.formatted',
|
||||
title: `Отчёт оформлен по шаблону: ${row.group_name}`,
|
||||
body: String(text || '').slice(0, 300),
|
||||
link: 'lessons.html',
|
||||
target: { lesson_report_id: row.id, lesson_date: String(row.lesson_date).slice(0, 10) },
|
||||
branchId: row.branch_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getAiPrompt() {
|
||||
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT);
|
||||
@@ -1947,30 +2037,33 @@ function normalizeOpenAiBase(base) {
|
||||
return b;
|
||||
}
|
||||
|
||||
async function aiCorrectText(text) {
|
||||
async function aiComplete(systemPrompt, userText, maxTokensOverride) {
|
||||
const profile = await getActiveAiProfile();
|
||||
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||||
let model = AI_MODEL;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
let maxTokens = Math.min(256, Math.max(128, text.length + 64));
|
||||
let maxTokens = Math.min(256, Math.max(128, userText.length + 64));
|
||||
if (profile) {
|
||||
const base = normalizeOpenAiBase(profile.base_url);
|
||||
if (!base) throw new Error('Некорректный base_url профиля ИИ');
|
||||
url = `${base}/chat/completions`;
|
||||
model = profile.model;
|
||||
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
|
||||
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
|
||||
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, userText.length * 2 + 512));
|
||||
}
|
||||
if (maxTokensOverride) maxTokens = Math.max(maxTokens, maxTokensOverride);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), AI_CALL_TIMEOUT_MS);
|
||||
try {
|
||||
const systemPrompt = await getAiPrompt();
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text }
|
||||
{ role: 'user', content: userText }
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: maxTokens
|
||||
@@ -1978,7 +2071,17 @@ async function aiCorrectText(text) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content?.trim() || text;
|
||||
return data.choices?.[0]?.message?.content?.trim() || '';
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function aiCorrectText(text) {
|
||||
try {
|
||||
const systemPrompt = await getAiPrompt();
|
||||
const out = await aiComplete(systemPrompt, text);
|
||||
return out || text;
|
||||
} catch (e) {
|
||||
console.error('AI correct error:', e);
|
||||
throw e;
|
||||
@@ -2339,11 +2442,24 @@ function normalizeRestoreData(data) {
|
||||
lesson_date: reqDate(x.lesson_date),
|
||||
lesson_time: optTime(x.lesson_time),
|
||||
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
|
||||
text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX),
|
||||
text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX),
|
||||
ai_status: optStr(x.ai_status, 20),
|
||||
ai_checked_at: optTs(x.ai_checked_at),
|
||||
ai_error: optStr(x.ai_error, 500),
|
||||
author_id: optInt(x.author_id, 0, 2147483647),
|
||||
branch_id: optInt(x.branch_id, 0, 2147483647),
|
||||
created_at: optTs(x.created_at),
|
||||
updated_at: optTs(x.updated_at),
|
||||
}));
|
||||
const lesson_report_versions = (data.lesson_report_versions || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
lesson_report_id: reqInt(x.lesson_report_id),
|
||||
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
|
||||
source: optStr(x.source, 20),
|
||||
author_id: optInt(x.author_id, 0, 2147483647),
|
||||
created_at: optTs(x.created_at),
|
||||
}));
|
||||
const settings = {};
|
||||
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
||||
@@ -2363,7 +2479,7 @@ function normalizeRestoreData(data) {
|
||||
created_at: optTs(x.created_at),
|
||||
finished_at: optTs(x.finished_at),
|
||||
}));
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports };
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions };
|
||||
}
|
||||
|
||||
const BACKUP_TTL_MS = 30 * 60 * 1000;
|
||||
@@ -2397,7 +2513,7 @@ function sweepBackupStorage() {
|
||||
async function buildBackupArchive() {
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||
try {
|
||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr] = await Promise.all([
|
||||
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv] = await Promise.all([
|
||||
pool.query('SELECT * FROM groups ORDER BY id'),
|
||||
pool.query('SELECT * FROM students ORDER BY id'),
|
||||
pool.query('SELECT * FROM entries ORDER BY id'),
|
||||
@@ -2413,10 +2529,11 @@ async function buildBackupArchive() {
|
||||
pool.query('SELECT * FROM share_links ORDER BY id'),
|
||||
pool.query('SELECT * FROM photo_jobs ORDER BY id'),
|
||||
pool.query('SELECT * FROM lesson_reports ORDER BY id'),
|
||||
pool.query('SELECT * FROM lesson_report_versions ORDER BY id'),
|
||||
]);
|
||||
const settings = {};
|
||||
st.rows.forEach(r => { settings[r.key] = r.value; });
|
||||
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows };
|
||||
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows };
|
||||
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||||
await storage.downloadAll(path.join(staging, 'uploads'));
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
||||
@@ -2573,6 +2690,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM project_files');
|
||||
await client.query('DELETE FROM lesson_report_versions');
|
||||
await client.query('DELETE FROM lesson_reports');
|
||||
await client.query('DELETE FROM entries');
|
||||
await client.query('DELETE FROM modules');
|
||||
@@ -2635,8 +2753,16 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
const ex = await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id]);
|
||||
if (!ex.rowCount) continue;
|
||||
await client.query(
|
||||
'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, text, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9)',
|
||||
[x.id, x.group_id, x.lesson_date, x.lesson_time, x.text, x.author_id, x.branch_id, x.created_at, x.updated_at]
|
||||
'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)',
|
||||
[x.id, x.group_id, x.lesson_date, x.lesson_time, x.text, x.text_original, x.text_ai, x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.author_id, x.branch_id, x.created_at, x.updated_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.lesson_report_versions) {
|
||||
const ex = await client.query('SELECT 1 FROM lesson_reports WHERE id = $1', [x.lesson_report_id]);
|
||||
if (!ex.rowCount) continue;
|
||||
await client.query(
|
||||
'INSERT INTO lesson_report_versions (id, lesson_report_id, text, source, author_id, created_at) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[x.id, x.lesson_report_id, x.text, x.source || 'manual', x.author_id, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.photo_jobs) {
|
||||
@@ -2679,7 +2805,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
[k, String(v ?? '')]
|
||||
);
|
||||
}
|
||||
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports']) {
|
||||
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions']) {
|
||||
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
|
||||
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
||||
}
|
||||
@@ -3628,6 +3754,8 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
|
||||
// --- Lesson reports (отчёт о занятии) ---
|
||||
const LESSON_REPORT_TEXT_MAX = 5000;
|
||||
const LESSON_REPORT_LIST_TTL_MS = 30 * 1000;
|
||||
const LESSON_AI_VERSION_LIMIT = 50;
|
||||
const LESSON_AI_DEFAULT_PROMPT = 'Ты — редактор деловых отчётов образовательного центра.\n\nТвоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.\n\nШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:\nОтчёт о проведённом занятии\nДата: <дата занятия>\nГруппа: <название группы>\nТемы: <перечень тем>\nПрактика: <задания>\nДомашнее задание: <что задано>\n\nПРАВИЛА:\n1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.\n2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.\n3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.\n4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.\n5. Не добавляй приветствия, подписи и какие-либо пояснения.\n6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.';
|
||||
|
||||
async function lessonReportGroup(user, groupId) {
|
||||
const gid = Number(groupId);
|
||||
@@ -3676,6 +3804,22 @@ function parseLessonReportTime(v) {
|
||||
return s.length === 5 ? s + ':00' : s;
|
||||
}
|
||||
|
||||
async function saveLessonReportVersion(reportId, text, source, userId) {
|
||||
if (!text || !text.trim()) return null;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO lesson_report_versions (lesson_report_id, text, source, author_id)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[reportId, text, source, userId || null]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM lesson_report_versions WHERE lesson_report_id = $1 AND id NOT IN (
|
||||
SELECT id FROM lesson_report_versions WHERE lesson_report_id = $1 ORDER BY id DESC LIMIT $2
|
||||
)`,
|
||||
[reportId, LESSON_AI_VERSION_LIMIT]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
app.get('/api/lesson-reports', requireAuth, async (req, res) => {
|
||||
const { date_from, date_to, group_id, search, limit, offset } = req.query;
|
||||
const conditions = [];
|
||||
@@ -3697,6 +3841,7 @@ app.get('/api/lesson-reports', requireAuth, async (req, res) => {
|
||||
const rows = await cacheWrap(key, LESSON_REPORT_LIST_TTL_MS, async () => {
|
||||
const qparams = params.slice();
|
||||
let q = `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.text, lr.author_id,
|
||||
lr.text_original, lr.text_ai, lr.ai_status, lr.ai_checked_at, lr.ai_error,
|
||||
lr.created_at, lr.updated_at, g.name AS group_name,
|
||||
u.name AS author_name, u.username AS author_username
|
||||
${from}
|
||||
@@ -3718,7 +3863,7 @@ app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/lesson-reports', requireAuth, async (req, res) => {
|
||||
const { group_id, lesson_date, lesson_time, text } = req.body || {};
|
||||
const { group_id, lesson_date, lesson_time, text, ai_check } = req.body || {};
|
||||
const grp = await lessonReportGroup(req.user, group_id);
|
||||
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
|
||||
const date = parseLessonReportDate(lesson_date);
|
||||
@@ -3735,28 +3880,38 @@ app.post('/api/lesson-reports', requireAuth, async (req, res) => {
|
||||
if (existing.length) {
|
||||
return res.status(409).json({ error: 'За эту группу и дату отчёт уже есть — откройте его для редактирования', id: existing[0].id });
|
||||
}
|
||||
const aiWanted = ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false';
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, author_id, branch_id)
|
||||
VALUES ($1, $2::date, $3, $4, $5, $6) RETURNING *`,
|
||||
[grp.group.id, date, time, body, req.user.id || null, grp.group.branch_id || null]
|
||||
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id)
|
||||
VALUES ($1, $2::date, $3, $4, $5, NULL, $6::text, CASE WHEN $6::text = 'none' THEN NULL ELSE now() END, NULL, $7, $8) RETURNING *`,
|
||||
[
|
||||
grp.group.id, date, time, body,
|
||||
aiWanted ? body : null,
|
||||
aiWanted ? 'pending' : 'none',
|
||||
req.user.id || null,
|
||||
grp.group.branch_id || null,
|
||||
]
|
||||
);
|
||||
await logAudit(req, 'lesson_report.create', { id: rows[0].id, group_id: grp.group.id, lesson_date: date });
|
||||
const created = rows[0];
|
||||
await saveLessonReportVersion(created.id, body, 'manual', req.user.id);
|
||||
await logAudit(req, 'lesson_report.create', { id: created.id, group_id: grp.group.id, lesson_date: date, ai_status: created.ai_status });
|
||||
invalidateLessonReports();
|
||||
if (aiWanted) wakeLessonAiWorker();
|
||||
await pushNotification({
|
||||
type: 'lesson.report',
|
||||
title: `Отчёт о занятии: ${grp.group.name}`,
|
||||
body: body.slice(0, 300),
|
||||
link: 'lessons.html',
|
||||
target: { lesson_report_id: rows[0].id, group_id: grp.group.id, lesson_date: date },
|
||||
target: { lesson_report_id: created.id, group_id: grp.group.id, lesson_date: date },
|
||||
branchId: grp.group.branch_id,
|
||||
});
|
||||
res.status(201).json(rows[0]);
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
const { lesson_date, lesson_time, text } = req.body || {};
|
||||
const { lesson_date, lesson_time, text, ai_check } = req.body || {};
|
||||
const curDate = String(report.lesson_date).slice(0, 10);
|
||||
let date = curDate;
|
||||
if (lesson_date !== undefined && lesson_date !== null && lesson_date !== '') {
|
||||
@@ -3778,16 +3933,97 @@ app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
|
||||
);
|
||||
if (clash.length) return res.status(409).json({ error: 'За эту группу и дату уже есть другой отчёт' });
|
||||
}
|
||||
const nextTime = time === undefined ? report.lesson_time : time;
|
||||
const aiWanted = !!body && ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false';
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE lesson_reports SET
|
||||
lesson_date = $1::date,
|
||||
lesson_time = $2,
|
||||
text = COALESCE($3, text),
|
||||
text_original = CASE WHEN $4::boolean THEN $3::text ELSE text_original END,
|
||||
text_ai = CASE WHEN $4::boolean THEN NULL ELSE text_ai END,
|
||||
ai_status = CASE WHEN $4::boolean THEN 'pending'::varchar ELSE ai_status END,
|
||||
ai_checked_at = CASE WHEN $4::boolean THEN now() ELSE ai_checked_at END,
|
||||
ai_error = CASE WHEN $4::boolean THEN NULL ELSE ai_error END,
|
||||
updated_at = now()
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[date, time === undefined ? report.lesson_time : time, body, report.id]
|
||||
WHERE id = $5 RETURNING *`,
|
||||
[date, nextTime, body, aiWanted, report.id]
|
||||
);
|
||||
await logAudit(req, 'lesson_report.update', { id: report.id, lesson_date: date });
|
||||
const updated = rows[0];
|
||||
if (body) await saveLessonReportVersion(report.id, body, 'manual', req.user.id);
|
||||
await logAudit(req, 'lesson_report.update', { id: report.id, lesson_date: date, ai_status: updated.ai_status });
|
||||
invalidateLessonReports();
|
||||
if (aiWanted) wakeLessonAiWorker();
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.get('/api/lesson-reports/:id/versions', requireAuth, async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
const { rows } = await pool.query(
|
||||
`SELECT v.id, v.lesson_report_id, v.text, v.source, v.created_at, u.name AS author_name, u.username AS author_username
|
||||
FROM lesson_report_versions v
|
||||
LEFT JOIN users u ON u.id = v.author_id
|
||||
WHERE v.lesson_report_id = $1
|
||||
ORDER BY v.id DESC LIMIT $2`,
|
||||
[report.id, LESSON_AI_VERSION_LIMIT]
|
||||
);
|
||||
res.json({ items: rows, current: report.text });
|
||||
});
|
||||
|
||||
app.post('/api/lesson-reports/:id/versions/:versionId/restore', requireAuth, async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
const vid = Number(req.params.versionId);
|
||||
if (!Number.isInteger(vid) || vid < 1) return res.status(400).json({ error: 'Некорректный идентификатор версии' });
|
||||
const { rows: vrows } = await pool.query(
|
||||
'SELECT * FROM lesson_report_versions WHERE id = $1 AND lesson_report_id = $2',
|
||||
[vid, report.id]
|
||||
);
|
||||
if (!vrows.length) return res.status(404).json({ error: 'Версия не найдена' });
|
||||
const version = vrows[0];
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE lesson_reports SET text = $1, text_ai = NULL, ai_status = 'reverted', ai_error = NULL,
|
||||
ai_checked_at = now(), updated_at = now()
|
||||
WHERE id = $2 RETURNING *`,
|
||||
[version.text, report.id]
|
||||
);
|
||||
const d = textDiff(report.text || '', version.text);
|
||||
await saveLessonReportVersion(report.id, version.text, 'restore', req.user.id);
|
||||
await logAudit(req, 'lesson_report.version.restore', {
|
||||
id: report.id,
|
||||
source: 'ai_revert',
|
||||
changed: d.changed,
|
||||
fields: d.changed ? ['text'] : [],
|
||||
changes: d.changed
|
||||
? [{ field: 'description', label: 'Текст отчёта', stats: d.stats, diff: d.segments, truncated: d.truncated }]
|
||||
: []
|
||||
});
|
||||
invalidateLessonReports();
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
app.post('/api/lesson-reports/:id/ai/revert', requireAuth, async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
if (!report.text_original) return res.status(400).json({ error: 'Оригинал текста недоступен' });
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE lesson_reports SET text = text_original, text_ai = NULL, ai_status = 'reverted',
|
||||
ai_error = NULL, ai_checked_at = now(), updated_at = now()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[report.id]
|
||||
);
|
||||
const d = textDiff(report.text || '', rows[0].text || '');
|
||||
await saveLessonReportVersion(report.id, rows[0].text, 'restore', req.user.id);
|
||||
await logAudit(req, 'lesson_report.ai.revert', {
|
||||
id: report.id,
|
||||
source: 'ai_revert',
|
||||
changed: d.changed,
|
||||
fields: d.changed ? ['text'] : [],
|
||||
changes: d.changed
|
||||
? [{ field: 'description', label: 'Текст отчёта', stats: d.stats, diff: d.segments, truncated: d.truncated }]
|
||||
: []
|
||||
});
|
||||
invalidateLessonReports();
|
||||
res.json(rows[0]);
|
||||
});
|
||||
@@ -6727,4 +6963,15 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
});
|
||||
photoWorker.start();
|
||||
console.log('Photo enhance worker started');
|
||||
lessonAiChecker = createLessonReportChecker({
|
||||
pool,
|
||||
getSetting,
|
||||
logAudit,
|
||||
aiUrl: AI_URL,
|
||||
defaultPrompt: LESSON_AI_DEFAULT_PROMPT,
|
||||
bus: createWorkerBus(LESSON_AI_WAKE_CHANNEL),
|
||||
onDone: onLessonAiDone,
|
||||
});
|
||||
lessonAiChecker.start();
|
||||
console.log('Lesson report AI worker started');
|
||||
})();
|
||||
|
||||
@@ -766,4 +766,269 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
||||
return { start, notify, getStats, getInfo };
|
||||
}
|
||||
|
||||
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };
|
||||
function createLessonReportChecker({ pool, getSetting, logAudit, aiUrl, model, defaultPrompt, bus, onDone }) {
|
||||
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
||||
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||
const DEFAULT_PROMPT = defaultPrompt || '';
|
||||
const TIMEOUT_MS = Math.max(5000, parseInt(process.env.LESSON_AI_TIMEOUT_MS || '120000', 10) || 120000);
|
||||
const MIN_CHARS = 20;
|
||||
const MAX_INPUT_CHARS = 4000;
|
||||
const TEXT_MAX = 5000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
let idleMs = IDLE_MIN_MS;
|
||||
let wake = null;
|
||||
let processing = false;
|
||||
let currentId = null;
|
||||
let startedAt = null;
|
||||
const stats = { checks: 0, formatted: 0, unchanged: 0, errors: 0, last_at: null, last_error: null };
|
||||
const attempts = new Map();
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => { wake = null; resolve(); }, ms);
|
||||
wake = () => { clearTimeout(t); wake = null; resolve(); };
|
||||
});
|
||||
}
|
||||
|
||||
function notify() {
|
||||
if (wake) wake();
|
||||
if (bus) bus.publish();
|
||||
}
|
||||
|
||||
if (bus) bus.subscribe(() => notify());
|
||||
|
||||
async function isEnabled() {
|
||||
return String(await getSetting('lesson_ai_enabled', 'true')) !== 'false';
|
||||
}
|
||||
|
||||
async function claimNext() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`SELECT lr.id, lr.text, lr.text_original, lr.lesson_date, lr.lesson_time, lr.author_id,
|
||||
g.name AS group_name, g.branch_id
|
||||
FROM lesson_reports lr
|
||||
JOIN groups g ON g.id = lr.group_id
|
||||
WHERE lr.ai_status = 'pending'
|
||||
ORDER BY lr.id ASC LIMIT 1 FOR UPDATE OF lr SKIP LOCKED`
|
||||
);
|
||||
if (!rows.length) {
|
||||
await client.query('COMMIT');
|
||||
return null;
|
||||
}
|
||||
await client.query(`UPDATE lesson_reports SET ai_status = 'processing' WHERE id = $1`, [rows[0].id]);
|
||||
await client.query('COMMIT');
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
async function resolveActiveProfile() {
|
||||
try {
|
||||
const active = await getSetting('ai_active_profile', 'native');
|
||||
if (active && active !== 'native') {
|
||||
const raw = await getSetting('ai_profiles', '[]');
|
||||
const list = JSON.parse(raw || '[]');
|
||||
if (Array.isArray(list)) {
|
||||
return list.find(p => p && p.id === active && p.base_url && p.model) || null;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeOpenAiBase(base) {
|
||||
const b = String(base || '').trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(b)) return null;
|
||||
if (!/\/v1$/i.test(b)) return b + '/v1';
|
||||
return b;
|
||||
}
|
||||
|
||||
async function callModel(userText, systemPrompt) {
|
||||
const profile = await resolveActiveProfile();
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
let url;
|
||||
let model;
|
||||
let maxTokens;
|
||||
if (profile) {
|
||||
const base = normalizeOpenAiBase(profile.base_url);
|
||||
if (!base) throw new Error('Некорректный base_url профиля ИИ');
|
||||
url = `${base}/chat/completions`;
|
||||
model = profile.model;
|
||||
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
|
||||
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, userText.length * 2 + 512));
|
||||
} else {
|
||||
url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||||
model = MODEL;
|
||||
maxTokens = Math.min(1536, Math.max(512, userText.length + 512));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userText },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
const choice = data.choices && data.choices[0];
|
||||
return (choice && choice.message && choice.message.content || '').trim();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutput(out) {
|
||||
let s = String(out || '').trim();
|
||||
if (!s) return '';
|
||||
s = s.replace(/^```[a-zA-Z]*\s*/, '').replace(/```$/, '').trim();
|
||||
s = s.replace(/^(?:итоговый|результативный|готовый|итог)\s*текст\s*(?:отчёта|отчета|report)?\s*[:—-]\s*/i, '').trim();
|
||||
s = s.replace(/^["'«”]+/, '').replace(/["'«»"]+$/, '').trim();
|
||||
s = s.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n');
|
||||
return s;
|
||||
}
|
||||
async function finish(row, status, text, error, aiText) {
|
||||
await pool.query(
|
||||
`UPDATE lesson_reports SET
|
||||
text = $1, text_ai = $2, ai_status = $3, ai_error = $4, ai_checked_at = now(), updated_at = now()
|
||||
WHERE id = $5`,
|
||||
[text, aiText, status, error || null, row.id]
|
||||
);
|
||||
attempts.delete(row.id);
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = error || null;
|
||||
if (onDone) await onDone({ row, status, text, aiText, error: error || null });
|
||||
}
|
||||
|
||||
async function processOne(row) {
|
||||
const original = String(row.text_original || row.text || '').trim();
|
||||
if (original.length < MIN_CHARS) {
|
||||
await finish(row, 'skipped', original, null, null);
|
||||
stats.unchanged++;
|
||||
return true;
|
||||
}
|
||||
const prompt = (await getSetting('lesson_ai_prompt', DEFAULT_PROMPT)) || DEFAULT_PROMPT;
|
||||
const date = String(row.lesson_date).slice(0, 10);
|
||||
const time = row.lesson_time ? String(row.lesson_time).slice(0, 5) : '';
|
||||
const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original;
|
||||
const context = [
|
||||
`Группа: ${row.group_name || '—'}`,
|
||||
`Дата занятия: ${date}`,
|
||||
time ? `Время занятия: ${time}` : '',
|
||||
'',
|
||||
'Текст отчёта:',
|
||||
input,
|
||||
].filter(l => l !== '').join('\n');
|
||||
try {
|
||||
const out = normalizeOutput(await callModel(context, prompt));
|
||||
if (!out || out.length > TEXT_MAX) {
|
||||
const reason = out ? 'Модель вернула слишком длинный текст' : 'Модель вернула пустой ответ';
|
||||
await finish(row, 'error', original, reason, null);
|
||||
stats.errors++;
|
||||
return true;
|
||||
}
|
||||
const changed = out !== original;
|
||||
await finish(row, changed ? 'done' : 'skipped', changed ? out : original, null, changed ? out : null);
|
||||
stats.checks++;
|
||||
if (changed) stats.formatted++; else stats.unchanged++;
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||
const tries = (attempts.get(row.id) || 0) + 1;
|
||||
if (tries < MAX_ATTEMPTS) {
|
||||
attempts.set(row.id, tries);
|
||||
await pool.query(`UPDATE lesson_reports SET ai_status = 'pending', ai_error = $1 WHERE id = $2`, [message, row.id]);
|
||||
if (logAudit) await logAudit(null, 'lesson_report.ai.retry', { id: row.id, attempt: tries, error: message });
|
||||
return false;
|
||||
}
|
||||
await finish(row, 'error', original, message, null);
|
||||
stats.errors++;
|
||||
if (logAudit) await logAudit(null, 'lesson_report.ai.error', { id: row.id, error: message });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
async function loop() {
|
||||
while (!stopped) {
|
||||
let row = null;
|
||||
try {
|
||||
if (!(await isEnabled())) {
|
||||
await sleep(IDLE_MAX_MS);
|
||||
continue;
|
||||
}
|
||||
row = await claimNext();
|
||||
} catch (e) {
|
||||
console.error('Lesson AI claim error:', e.message);
|
||||
await sleep(IDLE_MAX_MS);
|
||||
continue;
|
||||
}
|
||||
if (!row) {
|
||||
await sleep(idleMs);
|
||||
idleMs = Math.min(idleMs * 2, IDLE_MAX_MS);
|
||||
continue;
|
||||
}
|
||||
idleMs = IDLE_MIN_MS;
|
||||
processing = true;
|
||||
currentId = row.id;
|
||||
let ok = true;
|
||||
try {
|
||||
ok = await processOne(row);
|
||||
} catch (e) {
|
||||
console.error('Lesson AI process error:', e.message);
|
||||
} finally {
|
||||
processing = false;
|
||||
currentId = null;
|
||||
}
|
||||
if (!ok) await sleep(IDLE_MAX_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function resetStale() {
|
||||
return pool.query(`UPDATE lesson_reports SET ai_status = 'pending' WHERE ai_status = 'processing'`);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startedAt = Date.now();
|
||||
resetStale()
|
||||
.catch((e) => console.error('Lesson AI reset error:', e.message))
|
||||
.finally(() => { loop().catch((e) => console.error('Lesson AI loop error:', e.message)); });
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function getInfo() {
|
||||
return {
|
||||
started_at: startedAt ? new Date(startedAt).toISOString() : null,
|
||||
uptime_ms: startedAt ? Date.now() - startedAt : 0,
|
||||
processing,
|
||||
current_id: currentId,
|
||||
config: { idle_min_ms: IDLE_MIN_MS, idle_max_ms: IDLE_MAX_MS, timeout_ms: TIMEOUT_MS, model: MODEL, ai_url: AI_URL },
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
|
||||
return { start, stop, notify, getInfo };
|
||||
}
|
||||
|
||||
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker };
|
||||
|
||||
Reference in New Issue
Block a user