diff --git a/AGENTS.md b/AGENTS.md index c8a0e02..7f630fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) --- diff --git a/README.md b/README.md index e7306d1..61d70b9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - **Воспитанники** — справочник с привязкой к группам - **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат - **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников +- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции - **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`) diff --git a/api.smoketest.js b/api.smoketest.js index c9c4d99..0577b5a 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -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 }); diff --git a/db/init.sql b/db/init.sql index 33f83dd..ce63b9b 100644 --- a/db/init.sql +++ b/db/init.sql @@ -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; diff --git a/db/migration.sql b/db/migration.sql index 9551dc9..4be241b 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -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; diff --git a/public/admin.css b/public/admin.css index e36a464..27819ac 100644 --- a/public/admin.css +++ b/public/admin.css @@ -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} } diff --git a/public/admin.js b/public/admin.js index f858141..2b90d7d 100644 --- a/public/admin.js +++ b/public/admin.js @@ -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, решали задачи на списки, домашнее задание — функции"> 0 / 5000 + +
+
@@ -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 = ` +
+

История версий отчёта

+
Загрузка…
+
+ +
+
`; + 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 ` +
+
+ ${esc(label)} + ${esc(notifyTime(v.created_at))} +
+
${esc(v.text || '')}
+
+ ${who ? `${esc(who)}` : ''} + ${index === 0 ? 'текущая' : ''} + ${index === 0 ? '' : ``} +
+
`; +} + +async function openLessonVersions(id) { + currentLessonVersionId = String(id || ''); + const modal = ensureLessonVersionsModal(); + const list = modal.querySelector('#lessonVersionsList'); + list.innerHTML = '
Загрузка…
'; + 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 = `
${esc(data.error || 'Не удалось загрузить историю')}
`; return; } + const items = data.items || []; + if (!items.length) { list.innerHTML = '
Версий пока нет
'; return; } + list.innerHTML = items.map(lessonVersionItemHtml).join(''); + renderIcons(); + } catch { + list.innerHTML = '
Ошибка сети
'; + } +} + +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'); diff --git a/public/index.html b/public/index.html index 1d351f9..a0ada8b 100644 --- a/public/index.html +++ b/public/index.html @@ -150,7 +150,7 @@ nav a:hover{color:var(--text)}
- +
diff --git a/public/js/audit.js b/public/js/audit.js index 2e3b35d..ec33388 100644 --- a/public/js/audit.js +++ b/public/js/audit.js @@ -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) {
Технические данные (JSON)
${esc(fullTarget(target))}
`; } -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() { ${esc(r.ip || '')} `).join(''); } - renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage); + renderAuditPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage); } function goAuditPage(p) { diff --git a/public/js/lessons.js b/public/js/lessons.js index 21f87b9..ebd8dc2 100644 --- a/public/js/lessons.js +++ b/public/js/lessons.js @@ -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 ``; + if (st === 'done' && r.text_ai) return 'ИИ'; + if (st === 'skipped') return 'По шаблону'; + if (st === 'reverted') return 'Оригинал'; + if (st === 'error') return `Ошибка ИИ`; + return `${esc(st)}`; +} + 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 `
${esc(r.group_name || '')} ${esc(lessonWhenText(r))} ${isToday ? 'сегодня' : ''} + ${lessonAiBadgeHtml(r)}
+ + ${canRevert ? `` : ''} ${isAdmin() ? `` : ''}
@@ -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); diff --git a/public/js/settings.js b/public/js/settings.js index af63213..d02e6e7 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -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', diff --git a/public/settings.html b/public/settings.html index 5103328..2ea9cf9 100644 --- a/public/settings.html +++ b/public/settings.html @@ -29,6 +29,7 @@ Фото-ИИ Уведомления ИИ + Шаблон отчёта Бэкапы Корзина Блокировки @@ -165,6 +166,24 @@
+
+
+
+

Шаблон отчёта о занятии

+
+

Инструкция для нейросети: она сверяет текст отчёта тьютора с шаблоном делового сообщения. Если текст уже соответствует шаблону — остаётся без изменений, иначе приводится к деловому виду. Оригинал тьютора всегда сохраняется, результат можно посмотреть в истории версий.

+ +
+ + + Пусто — будет подставлен шаблон по умолчанию +
+
+
diff --git a/server.js b/server.js index b3754a2..39d6026 100644 --- a/server.js +++ b/server.js @@ -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'); })(); diff --git a/worker.js b/worker.js index a3e981b..755253d 100644 --- a/worker.js +++ b/worker.js @@ -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 };