diff --git a/AGENTS.md b/AGENTS.md index eb969fc..7111a3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -391,6 +391,8 @@ guards against. | `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование | | `public/chat.html` | Чат тьютор↔админ: список диалогов, лента сообщений, вложения (Ctrl+V) | | `public/js/chat.js` | Логика чата: загрузка треда/сообщений, отправка, вставка файлов из буфера | +| `public/feedback.html` | Страница обратной связи (отзывы тьютора) | +| `public/js/feedback.js` | Логика обратной связи: список, модалка, выбор short_messages, ИИ-очередь | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) | diff --git a/FEEDBACK_TODO.md b/FEEDBACK_TODO.md new file mode 100644 index 0000000..9fc8dd4 --- /dev/null +++ b/FEEDBACK_TODO.md @@ -0,0 +1,211 @@ +# TODO: Раздел «Обратная связь» (feedbacks) + +Цель: добавить новый раздел **«Обратная связь»** по образцу `lessons`, но с отдельным воркером и отдельной таблицей `feedbacks`. + +## Принятые решения + +- [ ] **Имя сущности**: `feedbacks` (API `/api/feedbacks`, страница `public/feedback.html`, JS `public/js/feedback.js`) +- [ ] **«Прошлый отзыв» / короткие сообщения**: брать `GET /api/shorts?search=<ФИО резидента>` (все отмечены по умолчанию). Отдельное поле `past_review` в модалке (чекбокс «Добавить в промпт прошлый отзыв» → textarea) +- [ ] **Помечать `short_messages.used`**: **да**, при создании отзыва (`POST /api/feedbacks`) для выбранных `short_message_ids` проставить `used=true, used_at = CURRENT_DATE`. Вызывать `invalidateShorts()` после этого +- [ ] **`ai_profile`**: **per-row** хранить в `feedbacks.ai_profile`. Воркер берёт его с фоллбеком на `ai_active_profile` +- [ ] **История версий**: **не добавлять** в MVP (без `feedback_versions`) +- [ ] **apiV1**: **не добавлять** сейчас + +--- + +## Этап 1. Константы, промпт по умолчанию (server.js) + +**Файл:** `server.js` + +- [ ] Добавить константы рядом с `LESSON_AI_*` (~строка 4890): + - `const FEEDBACK_AI_VERSION_LIMIT = 50;` + - `const FEEDBACK_AI_TIMEOUT_MS = 120000;` + - `const FEEDBACK_LIST_TTL_MS = 30 * 1000;` + - `const FEEDBACK_TEXT_MAX = 5000;` + - `const FEEDBACK_TOPIC_MAX = 500;` + - `const FEEDBACK_RESIDENT_MAX = 200;` + +- [ ] Добавить `FEEDBACK_AI_DEFAULT_PROMPT` рядом с `LESSON_AI_DEFAULT_PROMPT` (~4892–4927). В промпте учесть: Дата занятия, Группа, Резидент, Тема занятия, «Прошлый отзыв» (`past_review`), выбранные короткие сообщения (`дата – тема – сообщение`), нейтральный деловой стиль 1–3 предложения + +--- + +## Этап 2. БД — таблица `feedbacks` (3 места) + +### 2.1 `db/init.sql` (после блока `short_messages`, после ~482) + +- [ ] Добавить `CREATE TABLE feedbacks` со всеми полями и индексами: + - `id SERIAL PRIMARY KEY` + - `feedback_date DATE NOT NULL` + - `group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE` + - `resident VARCHAR(200) NOT NULL` + - `topic VARCHAR(500)` + - `past_review TEXT` + - `short_message_ids INT[] DEFAULT '{}'::int[]` + - `text TEXT NOT NULL DEFAULT ''` + - `ai_status VARCHAR(20) NOT NULL DEFAULT 'none'` (`none|pending|processing|done|error|skipped`) + - `ai_checked_at TIMESTAMPTZ` + - `ai_error TEXT` + - `ai_profile VARCHAR(100)` + - `author_id INT REFERENCES users(id) ON DELETE SET NULL` + - `branch_id INT REFERENCES branches(id) ON DELETE SET NULL` + - `deleted_at TIMESTAMPTZ`, `purge_at TIMESTAMPTZ` + - `created_at TIMESTAMPTZ DEFAULT now()`, `updated_at TIMESTAMPTZ DEFAULT now()` +- [ ] Индексы: `idx_feedbacks_date(feedback_date DESC)`, `idx_feedbacks_deleted(deleted_at)`, `idx_feedbacks_pending(ai_status, id)` +- [ ] Сиды настроек (~рядом с `notify_lesson_ai_formatted`, ~461): + - `('feedback_ai_enabled','true') ON CONFLICT DO NOTHING` + - `('feedback_ai_prompt','') ON CONFLICT DO NOTHING` + - `('notify_feedback_ai_ready','false') ON CONFLICT DO NOTHING` + +### 2.2 `db/migration.sql` (после блока `short_messages`, после ~591) + +- [ ] `DO $$ ... CREATE TABLE IF NOT EXISTS feedbacks ... $$` с теми же индексами +- [ ] `ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ...` для всех колонок (идемпотентно) +- [ ] Те же 3 сида `INSERT ... ON CONFLICT DO NOTHING` +- [ ] `UPDATE settings SET value = '<новый промпт>' WHERE key = 'feedback_ai_prompt' AND value IN ('<старый>')` (заготовка по образцу строки 507) + +### 2.3 `server.js` — `ensureFeedbacksTable()` (после `ensureShortMessagesTable()`, после ~2645) + +- [ ] Создать `async function ensureFeedbacksTable()` по образцу `ensureLessonReportsTable()` (`1570–1609`): CREATE TABLE IF NOT EXISTS + все `ADD COLUMN IF NOT EXISTS` + индексы + сиды `settings` +- [ ] В bootstrap (`~8757`) добавить вызов: + ```js + try { await ensureFeedbacksTable(); } catch (err) { console.error('Feedbacks table:', err); } + ``` + +--- + +## Этап 3. Инвалидация, каналы, wake-функции (server.js) + +- [ ] `const FEEDBACK_WAKE_CHANNEL = 'whatido:wake:feedback'` (~131–133) +- [ ] `let feedbackWorker = null;` (~134) +- [ ] `function invalidateFeedbacks() { cacheDrop('feedbacks:'); broadcastFeedbackChanged(); }` (~121–124) +- [ ] `function broadcastFeedbackChanged() { publish(EVENTS_CHANNEL, { type: 'feedbacks_changed', ts: Date.now() }); }` (~189–192) +- [ ] `function wakeFeedbackWorker() { if (feedbackWorker) feedbackWorker.notify(); }` (~3367–3369) +- [ ] `function onFeedbackDone(row, status, text, aiText, error)` по образцу `onLessonAiDone` (~3371–3405): `invalidateFeedbacks()`, publish `feedback_status`, при `done` — `logAudit('feedback.ai.generate', ...)`, `pushNotification('feedback.ai.ready', ..., branchId)` + +--- + +## Этап 4. Воркер `createFeedbackWriter` (worker.js) + +- [ ] (Опц.) Рефактор: вынести `resolveActiveProfile`+`callModel` в `createAiCaller` и переиспользовать в `createLessonReportChecker` и новом воркере. **Или** просто скопировать в новый воркер +- [ ] Добавить `createFeedbackWriter(opts)` после `createLessonReportChecker` (~перед экспортом 1052) + - Параметры: `{ pool, getSetting, logAudit, aiUrl, defaultPrompt, bus, onDone }` + - Константы: `MAX_ATTEMPTS=3`, `IDLE_MIN_MS=500`, `IDLE_MAX_MS=30000`, `MIN_CHARS=1`, `MAX_INPUT_CHARS=6000`, `TEXT_MAX=FEEDBACK_TEXT_MAX` + - `isEnabled()` → `getSetting('feedback_ai_enabled') !== 'false'` + - `notify()` + `bus.subscribe(FEEDBACK_WAKE_CHANNEL, notify)` + - `claimNext()` — SELECT `feedbacks f` с `ai_status='pending' AND deleted_at IS NULL`, `FOR UPDATE OF f SKIP LOCKED`, UPDATE → `processing`, вернуть строку (+JOIN `groups` при желании) + - `processOne(row)`: собрать контекст (Группа/Дата/Резидент/Тема/Прошлый отзыв), загрузить выбранные `short_messages` по `short_message_ids` (только `deleted_at IS NULL`), форматировать блок, вызвать модель с `profileId = row.ai_profile` (фолбек на активный), `normalizeOutput`, принять решение `done|skipped|error` + - `finish()` — UPDATE `feedbacks` (`ai_status`, `ai_checked_at`, `ai_error`, `text` при `done`, `updated_at`) + - `resetStale()` — `processing` → `pending` + - `loop()` с backoff, `start/stop/getInfo()` +- [ ] Добавить `createFeedbackWriter` в `module.exports` (~1054) +- [ ] В `server.js` bootstrap после `lessonAiChecker.start()` (~8805–8806) запустить `feedbackWorker` + +--- + +## Этап 5. Роуты API `/api/feedbacks` (server.js) + +Расположить **после** блока `/api/shorts` (~после 2843) + +- [ ] `GET /api/feedbacks` — пагинация `limit/offset`, фильтры `date_from/date_to/group_id/resident/search/status/deleted`, `branchScope` (`f`), кэш `feedbacks:list:*` с `FEEDBACK_LIST_TTL_MS`, ответ `{ items, total }` +- [ ] `GET /api/feedbacks/:id` — с JOIN `groups`, проверка филиала +- [ ] `POST /api/feedbacks` — валидация полей (дата `YYYY-MM-DD`, `resident` max 200, `topic` max 500, `text` max 5000, `group_id` int, `past_review` max 2000, `short_message_ids` массив int уникальные, `ai_check` boolean, `ai_profile` opt). Проверка доступа к группе. INSERT. Если `ai_check && feedback_ai_enabled!=='false'` → `ai_status='pending'`, `text=text||''`. **Пометить выбранные short_messages** `used=true, used_at=CURRENT_DATE` (только `deleted_at IS NULL`, можно `AND used=false`). `logAudit('feedback.create', {..., short_count})`. `invalidateFeedbacks()` + `invalidateShorts()`. Если `pending` → `wakeFeedbackWorker()`. 201 +- [ ] `PUT /api/feedbacks/:id` — редактирование по образцу, без смены `ai_status` по умолчанию. `logAudit`, `invalidateFeedbacks()` +- [ ] `DELETE /api/feedbacks/:id` — мягкое удаление (`deleted_at`, `purge_at = now()+trash_purge_days`) по образцу `short_messages:2794–2810` +- [ ] `PUT /api/feedbacks/:id/restore` (requireAdmin) — снять `deleted_at`, `purge_at` (2811–2827) +- [ ] `DELETE /api/feedbacks/:id/permanent` (requireAdmin) — плановое удаление (2828–2843) +- [ ] `POST /api/feedbacks/:id/ai/retry` (requireAuth) — `ai_status='error'→'pending'`, сброс `ai_error/ai_checked_at`, `wakeFeedbackWorker()`, `invalidateFeedbacks()` +- [ ] `POST /api/feedbacks/requeue-failed` (requireAuth, requireAdmin) — массовый перевод `error→pending`, wake + invalidate +- [ ] Хелпер `feedbackById(user,id)` по образцу `lessonReportById` (JOIN `groups g`, `branchScope`) + +--- + +## Этап 6. SSE, уведомления, аудит (server.js) + +- [ ] `dispatchEvent` (~154–163): добавить ветку `payload.type === 'feedback_status'` → `writeFrame('feedback_status', payload); return;` +- [ ] `NOTIFY_TYPES` (~234–246): добавить `'feedback.ai.ready'` с иконкой `message-square-heart`, `label: 'Отзыв сгенерирован'`, `hint`, `level:'info'`, `enabled:true`, `admin:false` +- [ ] `ensureFeedbacksTable()` уже добавил сид `notify_feedback_ai_ready='false'` (ок) +- [ ] Аудит-имена `feedback.create`, `feedback.ai.generate` используются выше + +--- + +## Этап 7. Бэкап/restore (`backup-restore.js`, `server.js`) + +- [ ] `backup-restore.js:6–12` (`BACKUP_TABLES`) — добавить `'feedbacks'` +- [ ] `backup-restore.js:13–18` (`BACKUP_SEQUENCE_TABLES`) — добавить `'feedbacks'` +- [ ] `backup-restore.js` блок `normalizeRestoreData` (~505–519) — добавить маппинг `feedbacks` с нормализацией `short_message_ids` (массив), `deleted_at`, `purge_at` +- [ ] `server.js:3574` (дамп) — добавить `feedbacks` в цикл выборки +- [ ] `server.js:3758` (restore DELETE) — `DELETE FROM feedbacks;` в FK-безопасном порядке (до `groups`) +- [ ] `server.js:3915–3918` (restore INSERT) — цикл INSERT для `feedbacks` + +--- + +## Этап 8. Фронтенд — `public/feedback.html` + +- [ ] Создать по образцу `public/lessons.html` (`data-page="feedback"`) +- [ ] `.page-head`: `h2 «Обратная связь»`, `.page-sub «Отзывы тьютора по итогам занятий»` +- [ ] `.filters`: `#fbSearch`, `#fbGroupFilter`, `#fbStatusFilter` (Все/Ожидает/В обработке/Готово/Ошибка/Пропущен), `#fbDateFrom`, `#fbDateTo`, `#fbDeletedToggle` (опц.), `#addFeedbackBtn` +- [ ] `#feedbackState`, `#feedbackList`, `#pager` +- [ ] Скрипты: `vendor/lucide.min.js`, `js/datetime.js`, `admin.js`, `js/feedback.js` + +Модалка `#feedbackModal` — динамическая в JS (по образцу `ensureLessonModal`) либо статичная. Рекомендуется динамическая. + +--- + +## Этап 9. Фронтенд — `public/js/feedback.js` + +- [ ] Стейт: `PAGE_SIZE=20`, `page=1`, `total=0`, `searchTimer`, `groupsLoaded`, `feedbacks=[]`, `shortsListCache`, `shortsFetchTimer` +- [ ] Форматтеры: `fbDate(d)` через `fmtShortIso`, `fbDay(d)` (короткий день недели) — безопасно через `new Date(iso+'T12:00:00')` +- [ ] `fbItemHtml(f)` — строка таблицы: Дата, Тема (дата+тема), Резидент, День, Группа, Сам отзыв (обрезка), Действия (`data-edit`, `data-del`, `data-retry` при `ai_status==='error'`) +- [ ] `loadFeedbacks()` — GET `/api/feedbacks` с фильтрами, рендер + `renderPager` +- [ ] `loadGroups()` — использовать `loadLessonGroups()` из `admin.js` +- [ ] `ensureFeedbackModal()` — динамическая модалка с полями: `#fbId`, `#fbDate` (default `todayIso()`), `#fbGroup`, `#fbResident`, `#fbTopic`, `#fbPastCheck`, `#fbPastReviewWrap`, `#fbPastReview`, `#fbShortsWrap`, `#fbShortsList`, `#fbText`, `#fbAiRow`, `#fbAiCheck`, `#fbModel`, `#fbAiStatus` + - Дебаунс ввода `#fbResident` (>=2) → `fetchShorts` → рендер чекбоксов **все отмечены** + - `#fbPastCheck` toggle +- [ ] `fetchShorts(q)` — `GET /api/shorts?search=...&limit=50&deleted=0`, кэш 5–10с +- [ ] `renderShortsList(items)` — чекбоксы `name="fbShort"` все `checked=true` +- [ ] `openFeedbackModal({id})` — reset + заполнение при редактировании +- [ ] `submitFeedbackModal(e)` — собрать все поля, POST/PUT, close + reload + тост +- [ ] `connectFeedbackLive()` — SSE `/api/events`, обработка `feedbacks_changed` (debounce) и `feedback_status` (обновить строку) +- [ ] Делегирование кликов `[data-edit]/[data-del]/[data-retry]` +- [ ] Фильтры с debounce 350мс, `page=1` при смене +- [ ] `initFeedback()` — `checkAuth()`, `buildSidebar('feedback')`, `loadGroups()`, загрузка профилей ИИ в `#fbModel` (`GET /api/ai/profiles`, первый `native`), `loadFeedbacks()`, `connectFeedbackLive()`, `lucide.createIcons()` + +--- + +## Этап 10. Сайдбар (public/admin.js) + +- [ ] В массив `base` (~50–63) добавить `{ page: 'feedback', label: 'Обратная связь', icon: 'message-square-heart' }` **после** `lessons` (строка 53) + +--- + +## Этап 11. Документация (AGENTS.md) + +- [ ] Добавить раздел **3h. Обратная связь (feedbacks)** после 3g. Чат: таблица, настройки (`feedback_ai_enabled`, `feedback_ai_prompt`, `notify_feedback_ai_ready`), воркер `createFeedbackWriter`, wake `wakeFeedbackWorker()`, SSE `feedbacks_changed`/`feedback_status`, уведомление `feedback.ai.ready`, per-row `ai_profile`, отметка `short_messages.used` +- [ ] В **File Map** добавить `public/feedback.html`, `public/js/feedback.js`. Упомянуть `feedbacks` в backup-restore контексте + +--- + +## Этап 12. Проверка + +- [ ] `node diff.selftest.js` — ок +- [ ] `node backup.selftest.js` — должен пройти с `feedbacks` в normalizeRestoreData +- [ ] `docker compose down && docker compose up -d --build` +- [ ] `docker compose logs -f app` — нет ошибок при старте (ensureFeedbacksTable + feedbackWorker) +- [ ] БД: `docker compose exec db psql -U app -d whereldo -c '\d feedbacks'` — все колонки есть +- [ ] Настройки: `SELECT key,value FROM settings WHERE key LIKE 'feedback_%';` — 3 записи есть +- [ ] `node api.smoketest.js` — регрессий нет +- [ ] Ручной UI: `/admin/feedback.html` → список грузится, «Добавить отзыв» → модалка, ввод ФИО ≥2 → список short_messages **все чекнуты**, выбор модели из профилей, «Отправить в очередь» → `ai_status=pending`, воркер забирает, статус меняется на `done` (если модель доступна) +- [ ] После создания отзыва проверить `short_messages.used` у выбранных + +--- + +## Критичные моменты + +- [ ] **Три копии схемы** синхронизированы: `db/init.sql`, `db/migration.sql`, `server.js::ensureFeedbacksTable()` +- [ ] **Промпт в трёх местах + UPDATE в migration.sql** (как `lesson_ai_prompt`, правило §2 AGENTS) +- [ ] **`short_message_ids` — INT[]**: в `normalizeRestoreData` приводить к массиву, в INSERT передавать `::int[]` +- [ ] **Филиалы**: `feedbackById` и `GET /api/feedbacks` используют `branchScope`/проверку филиала +- [ ] **SSE**: ветка `feedback_status` добавлена в `dispatchEvent` **до** возврата +- [ ] **`invalidateShorts()`** после POST feedback (изменение `used`) +- [ ] **wakeFeedbackWorker()** вызывается только при `ai_status==='pending'` +- [ ] **FK-безопасный порядок DELETE при restore**: `DELETE FROM feedbacks;` до `groups` \ No newline at end of file diff --git a/backup-restore.js b/backup-restore.js index 03d56f6..4819a78 100644 --- a/backup-restore.js +++ b/backup-restore.js @@ -8,13 +8,13 @@ const BACKUP_TABLES = [ 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', 'notification_reads', 'banned_ips', 'chat_threads', 'chat_messages', 'chat_attachments', - 'short_messages', + 'short_messages', 'feedbacks', ]; const BACKUP_SEQUENCE_TABLES = [ 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', 'chat_threads', 'chat_messages', - 'chat_attachments', 'short_messages', + 'chat_attachments', 'short_messages', 'feedbacks', ]; function isSupportedBackupVersion(data) { @@ -516,7 +516,28 @@ function normalizeRestoreData(data) { created_at: optTs(x.created_at), updated_at: optTs(x.updated_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, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips, chat_threads, chat_messages, chat_attachments, short_messages }; + const feedbacks = (data.feedbacks || []).map(r => ({ + id: reqInt(r.id), + feedback_date: optDate(r.feedback_date), + group_id: reqInt(r.group_id), + resident: reqStr(r.resident), + topic: optStr(r.topic), + past_review: optStr(r.past_review), + short_message_ids: Array.isArray(r.short_message_ids) ? r.short_message_ids.map(v => reqInt(v)) : [], + text: optStr(r.text), + ai_status: optStr(r.ai_status) || 'none', + ai_checked_at: optTs(r.ai_checked_at), + ai_error: optStr(r.ai_error), + ai_profile: optStr(r.ai_profile), + sent_at: optTs(r.sent_at), + author_id: optInt(r.author_id), + branch_id: optInt(r.branch_id), + deleted_at: optTs(r.deleted_at), + purge_at: optTs(r.purge_at), + created_at: optTs(r.created_at), + updated_at: optTs(r.updated_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, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips, chat_threads, chat_messages, chat_attachments, short_messages, feedbacks }; } module.exports = { diff --git a/db/init.sql b/db/init.sql index ebdf74f..6fbef17 100644 --- a/db/init.sql +++ b/db/init.sql @@ -460,6 +460,32 @@ INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — реда ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('feedback_ai_enabled','true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('feedback_ai_prompt','Ты — помощник по написанию коротких деловых отзывов тьютора детской IT-школы. + +ЗАДАЧА: Сформируй короткий, ёмкий, деловой отзыв по итогам занятия. По стилю он должен быть близок к «короткому сообщению для обратной связи»: 1–3 предложения, без воды, конкретно по делу. + +КОНТЕКСТ, КОТОРЫЙ ТЫ ПОЛУЧИШЬ: +- Группа: ... +- Дата занятия: ... +- Резидент: ... +- Тема занятия: ... +- Позиция темы: ... (если есть) +- Прошлый отзыв: ... (если есть) +- Короткие сообщения (выбраны): список в формате "дата – тема – сообщение" + +ПРАВИЛА: +- Пиши только отзыв. Без заголовков, списков, markdown, подписей и пояснений. +- Не упоминай группу, дату, время занятия в тексте отзыва. +- Не начинай со слов «Сегодня», «Вчера», «На занятии», «В ходе занятия», если они избыточны. +- Опирайся на факты из контекста: тема занятия, выбранные короткие сообщения и прошлый отзыв (если есть). +- Сохраняй деловой, спокойный тон. Без оценок типа «отлично», «замечательно», избегай шаблонных «молодец». +- Максимально кратко: 1–2 предложения. Если хватает одного — лучше одно. +- Не оборачивай ответ в кавычки. +- Не придумывай фактов, которых нет в контексте. + +ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_feedback_ai_ready','false') ON CONFLICT (key) DO NOTHING; CREATE TABLE IF NOT EXISTS short_messages ( id SERIAL PRIMARY KEY, @@ -476,7 +502,33 @@ CREATE TABLE IF NOT EXISTS short_messages ( updated_at TIMESTAMPTZ DEFAULT now() ); -CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC); CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at); +CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC); INSERT INTO settings (key, value) VALUES ('short_messages_retention_days', '365') ON CONFLICT (key) DO NOTHING; + +-- Обратная связь (отзывы тьютора) +CREATE TABLE IF NOT EXISTS feedbacks ( + id SERIAL PRIMARY KEY, + feedback_date DATE NOT NULL, + group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + resident VARCHAR(200) NOT NULL, + topic VARCHAR(500), + past_review TEXT, + short_message_ids INT[] DEFAULT '{}'::int[], + text TEXT NOT NULL DEFAULT '', + ai_status VARCHAR(20) NOT NULL DEFAULT 'none', + ai_checked_at TIMESTAMPTZ, + ai_error TEXT, + ai_profile VARCHAR(100), + sent_at TIMESTAMPTZ, + author_id INT REFERENCES users(id) ON DELETE SET NULL, + branch_id INT REFERENCES branches(id) ON DELETE SET NULL, + deleted_at TIMESTAMPTZ, + purge_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC); +CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at); +CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id); diff --git a/db/migration.sql b/db/migration.sql index d57b08b..db576cf 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -456,31 +456,32 @@ INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — реда ГЛАВНОЕ ПРАВИЛО: Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.') ON CONFLICT (key) DO NOTHING; + UPDATE settings SET value = 'Ты — редактор сообщений тьютора детской IT-школы. -Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть. +ЦЕЛЬ +Привести текст отчёта о занятии к деловому стилю по строгому шаблону. Начало фразы должно соответствовать позиции темы (первое/промежуточное/последнее занятие модуля), исходя из формулировки «Позиция темы: ...». -ГЛАВНОЕ: -- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл. -- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык». -- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала. +ПРАВИЛА ФОРМУЛИРОВКИ +- Всегда начинай фразу строго в соответствии с «Позиция темы»: + - если «первое занятие модуля (1 из N)» — начинай с «На занятии ребята начали...» (или близко по смыслу, без шаблонного «На данном занятии») + - если «промежуточное занятие модуля (K из N), K > 1 и K < N» — начинай с «На занятии ребята продолжили...» + - если «последнее занятие модуля (N из N)» — начинай с «На занятии ребята завершили...» (при N==1 — это «последнее занятие модуля (1 из 1)», используй «завершили») +- Определи позицию ТОЛЬКО по строке «Позиция темы: ...». Не угадывай по номеру темы сам. +- Если «Позиция темы» отсутствует или не содержит N/M — начни нейтрально, без жёсткого шаблона, но сохрани деловой стиль. +- Перепиши текст в деловом стиле, сохрани смысл, факты и ключевые термины. +- Убери разговорные слова, лишние повторы, воду. +- Не добавляй факты, которых нет в исходном тексте. +- Не используй оценочные слова («отлично», «хорошо», «замечательно»), не пиши «молодцы». -СТИЛЬ И СТРУКТУРА: -- Один цельный абзац, 2–4 предложения, от третьего лица. -- Первое предложение всегда начинай со слов «На занятии ребята …». Чем занимались в начале занятия — начало модуля, продолжение или завершение — определяй по правилу НАЧАЛО ФРАЗЫ. -- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её. -- Вместо «они» пиши «каждый». Обращение на «вы» не используй. -- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно. -- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами. -- Убери разговорные и оценочные обороты: «было весело», «очень», «классно». +ЗАПРЕТЫ +- Не упоминай дату, время, группу, номер занятия и модуль. +- Не начинай со слов «Сегодня», «Вчера». +- Не используй шаблон «На данном занятии», «В рамках занятия». +- Не пиши список (маркированный/нумерованный). Только сплошной текст. -НАЧАЛО ФРАЗЫ (определяется номером темы): -Система присылает готовую подсказку строкой «Позиция темы: …». Если такой строки нет, смотри номер вида N/M в конце строки «Тема занятия: …», где N — номер занятия в модуле, M — сколько занятий в модуле всего. Пользуйся только одной из этих подсказок и всегда проверяй, что N не больше M. -- «последнее занятие модуля» (N = M, например 2/2 или 7/7): занятие завершает модуль. Первое предложение обязательно начни с «завершили» или «закончили»: «На занятии ребята завершили изучение …», «На занятии ребята закончили знакомство с …». -- «промежуточное занятие модуля» (1 < N < M, например 2/4, 3/5, 6/7): изучение продолжается. Первое предложение обязательно начни с «продолжили», «продолжали», «закрепили» или «углубили»: «На занятии ребята продолжили изучение …», «На занятии ребята закрепили …». -- «первое занятие модуля» (N = 1 и N < M, например 1/4 или 1/7): модуль начинается. Первое предложение обязательно начни с «начали» или «приступили»: «На занятии ребята начали знакомство с …», «На занятии ребята начали изучение …». -- Если подсказки нет или номер нечитаем — характер занятия не выбирай и не додумывай. Начни нейтрально, прямо с сути: «На занятии ребята разобрались …», «На занятии ребята отработали …», «На занятии ребята попробовали …». +ГЛАВНОЕ ПРАВИЛО - Это требование сильнее всего остального: даже если начало исходного текста уже хорошо звучит, перепиши его по правилу выше. Слово «модуль», цифры номера и любые оценки в текст отчёта не переноси: номер темы — внутренний признак для выбора формулировки. ФОРМАТ: @@ -501,7 +502,6 @@ SET value = 'Ты — редактор сообщений тьютора дет «На занятии ребята продолжили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…». Тот же исходный текст, но «Позиция темы: последнее занятие модуля (3 из 3)»: «На занятии ребята завершили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…». - ГЛАВНОЕ ПРАВИЛО: Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.' WHERE key = 'lesson_ai_prompt' AND value IN ( @@ -589,3 +589,84 @@ ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS used_at DATE; ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL; INSERT INTO settings (key, value) VALUES ('short_messages_retention_days', '365') ON CONFLICT (key) DO NOTHING; + +-- Feedbacks +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'feedbacks') THEN + CREATE TABLE feedbacks ( + id SERIAL PRIMARY KEY, + feedback_date DATE NOT NULL, + group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + resident VARCHAR(200) NOT NULL, + topic VARCHAR(500), + past_review TEXT, + short_message_ids INT[] DEFAULT '{}'::int[], + text TEXT NOT NULL DEFAULT '', + ai_status VARCHAR(20) NOT NULL DEFAULT 'none', + ai_checked_at TIMESTAMPTZ, + ai_error TEXT, + ai_profile VARCHAR(100), + author_id INT REFERENCES users(id) ON DELETE SET NULL, + branch_id INT REFERENCES branches(id) ON DELETE SET NULL, + deleted_at TIMESTAMPTZ, + purge_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC); + CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at); + CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id); + END IF; +END$$; + +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS feedback_date DATE; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS group_id INT; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS resident VARCHAR(200); +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS topic VARCHAR(500); +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS past_review TEXT; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS short_message_ids INT[] DEFAULT '{}'::int[]; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS text TEXT NOT NULL DEFAULT ''; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100); +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT now(); +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); + +-- Индексы (если таблица существовала без них) +CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC); +CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at); +CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id); + +INSERT INTO settings (key, value) VALUES ('feedback_ai_enabled','true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('feedback_ai_prompt','Ты — помощник по написанию коротких деловых отзывов тьютора детской IT-школы. + +ЗАДАЧА: Сформируй короткий, ёмкий, деловой отзыв по итогам занятия. По стилю он должен быть близок к «короткому сообщению для обратной связи»: 1–3 предложения, без воды, конкретно по делу. + +КОНТЕКСТ, КОТОРЫЙ ТЫ ПОЛУЧИШЬ: +- Группа: ... +- Дата занятия: ... +- Резидент: ... +- Тема занятия: ... +- Позиция темы: ... (если есть) +- Прошлый отзыв: ... (если есть) +- Короткие сообщения (выбраны): список в формате "дата – тема – сообщение" + +ПРАВИЛА: +- Пиши только отзыв. Без заголовков, списков, markdown, подписей и пояснений. +- Не упоминай группу, дату, время занятия в тексте отзыва. +- Не начинай со слов «Сегодня», «Вчера», «На занятии», «В ходе занятия», если они избыточны. +- Опирайся на факты из контекста: тема занятия, выбранные короткие сообщения и прошлый отзыв (если есть). +- Сохраняй деловой, спокойный тон. Без оценок типа «отлично», «замечательно», избегай шаблонных «молодец». +- Максимально кратко: 1–2 предложения. Если хватает одного — лучше одно. +- Не оборачивай ответ в кавычки. +- Не придумывай фактов, которых нет в контексте. + +ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_feedback_ai_ready','false') ON CONFLICT (key) DO NOTHING; diff --git a/docker-compose.yml b/docker-compose.yml index 901014b..aec368c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -216,9 +216,14 @@ services: PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan} PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0} PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92} + HF_HOME: /root/.cache/huggingface + TORCH_HOME: /root/.cache/torch + TRANSFORMERS_CACHE: /root/.cache/huggingface + XDG_CACHE_HOME: /root/.cache TZ: Europe/Moscow volumes: - photo-ai-models:/models + - photo-ai-cache:/root/.cache ports: - "127.0.0.1:8081:8080" healthcheck: @@ -232,5 +237,6 @@ services: volumes: pgdata: photo-ai-models: + photo-ai-cache: redis-data: s3-data: diff --git a/photo-ai/Dockerfile b/photo-ai/Dockerfile index 46e5a64..9863210 100644 --- a/photo-ai/Dockerfile +++ b/photo-ai/Dockerfile @@ -6,14 +6,26 @@ ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT} WORKDIR /app +ENV PIP_NO_CACHE_DIR=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + HF_HOME=/root/.cache/huggingface \ + TORCH_HOME=/root/.cache/torch \ + TRANSFORMERS_CACHE=/root/.cache/huggingface \ + XDG_CACHE_HOME=/root/.cache + RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy==2.2.6" +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --upgrade pip && \ + pip install "typing-extensions==4.12.2" "numpy==2.2.6" -RUN pip install --no-cache-dir torch torchvision --index-url ${TORCH_INDEX} +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install torch torchvision --index-url ${TORCH_INDEX} -RUN pip install --no-cache-dir --no-deps basicsr==1.4.2 realesrgan==0.3.0 gfpgan==1.3.8 facexlib==0.3.0 && \ - pip install --no-cache-dir "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \ +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --no-deps basicsr==1.4.2 realesrgan==0.3.0 gfpgan==1.3.8 facexlib==0.3.0 && \ + pip install "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \ requests scikit-image scipy tqdm filterpy numba fastapi "uvicorn[standard]" python-multipart RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \ diff --git a/public/admin.css b/public/admin.css index 43afc5c..0e84069 100644 --- a/public/admin.css +++ b/public/admin.css @@ -81,6 +81,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end} .filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem} .filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px} +.filters label.toggle{display:flex;align-items:center;gap:8px;margin-bottom:0;padding:6px 10px;color:var(--text);background:transparent;border-color:transparent;min-width:0} +.filters label.toggle .toggle-text{font-size:.8rem;white-space:nowrap} +.filters > div{min-width:0} .search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none} .search-input:focus{border-color:var(--accent)} #studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px} @@ -489,6 +492,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .ai-badge-done{background:rgba(22,163,74,.12);color:#16a34a} .ai-badge-error{background:rgba(239,68,68,.12);color:#ef4444} .ai-badge-skipped,.ai-badge-reverted{background:rgba(148,163,184,.18);color:var(--muted)} +.ai-badge-sent{background:rgba(20,184,166,.12);color:#14b8a6} .ai-badge-ic.ai-badge-processing .lucide-loader{animation:spin 1s linear infinite} @keyframes spin{to{transform:rotate(360deg)}} .ai-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;cursor:pointer;white-space:nowrap} diff --git a/public/admin.js b/public/admin.js index edd7cf9..70daee9 100644 --- a/public/admin.js +++ b/public/admin.js @@ -51,6 +51,7 @@ function buildSidebar(active) { { page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' }, { page: 'journal', label: 'Журнал', icon: 'book-open' }, { page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' }, + { page: 'feedback', label: 'Обратная связь', icon: 'message-square-heart' }, { page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true }, { page: 'chat', label: 'Чаты', icon: 'messages-square', chatBadge: true }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' }, diff --git a/public/feedback.html b/public/feedback.html new file mode 100644 index 0000000..729a528 --- /dev/null +++ b/public/feedback.html @@ -0,0 +1,145 @@ + + + + + + Обратная связь — WhatIDo + + + + +
+ +
+
+
+

Обратная связь

+
Отзывы тьютора по итогам занятий
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/public/js/feedback.js b/public/js/feedback.js new file mode 100644 index 0000000..464c283 --- /dev/null +++ b/public/js/feedback.js @@ -0,0 +1,398 @@ +(async function() { + const API = location.origin; + const PAGE_SIZE = 20; + let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = ''; + const topicReportCache = new Map(); + + function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; } + function fmtDate(d) { if (!d) return ''; if (window.datetime && datetime.fmtShortIso) return datetime.fmtShortIso(d); return d; } + function fmtDay(d) { + if (!d) return ''; + try { const dt = new Date(d + 'T12:00:00'); return dt.toLocaleDateString('ru-RU', { weekday: 'short' }); } catch (e) { return ''; } + } + function escapeHtml(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } + + function aiBadge(s) { + switch (s) { + case 'pending': return 'Ожидает'; + case 'processing': return 'В обработке'; + case 'done': return 'Готово'; + case 'error': return 'Ошибка'; + case 'skipped': return 'Пропущен'; + default: return 'Без ИИ'; + } + } + + function fbItemHtml(f) { + const text = (f.text||'').trim(); + const short = text.length > 100 ? text.slice(0,97)+'…' : text; + const del = f.deleted_at ? ' style="opacity:.5"' : ''; + return `
+
${fmtDate(f.feedback_date)}
+
${escapeHtml(f.topic||'')}
+
${escapeHtml(f.resident||'')}
+
${fmtDay(f.feedback_date)}
+
${escapeHtml(f.group_name||'')}
+
${escapeHtml(short)}
+
+ ${aiBadge(f.ai_status)} + ${f.sent_at ? 'Отправлено' : ''} + ${f.ai_status === 'error' ? `` : ''} + ${f.deleted_at ? '' : ``} + + + ${f.deleted_at ? `` : ''} +
+
`; + } + + function ensureModal() { + let m = document.getElementById('feedbackModal'); + if (m) return m; + m = document.createElement('div'); + m.className = 'modal-overlay'; + m.id = 'feedbackModal'; + document.body.appendChild(m); + return m; + } + + function openModal({id}) { + const m = document.getElementById('feedbackModal'); + if (!m) return; + m.classList.add('open'); + resetForm(); + if (id) loadFeedback(id); + } + function closeModal() { + const m = document.getElementById('feedbackModal'); + if (m) m.classList.remove('open'); + } + + function todayIso() { + try { if (window.datetime && datetime.todayIso) return datetime.todayIso(); } catch (e) {} + const d = new Date(); const y=d.getFullYear(), mo=String(d.getMonth()+1).padStart(2,'0'), da=String(d.getDate()).padStart(2,'0'); return `${y}-${mo}-${da}`; + } + + let residentNamesLoaded = false; + const residentGroups = new Map(); + const normName = s => String(s || '').trim().replace(/\s+/g, ' ').toLowerCase(); + async function loadResidentNames() { + if (residentNamesLoaded) return; + try { + const r = await fetch(`${API}/api/students/names-with-groups`, { headers: hdr() }); + if (!r.ok) return; + const rows = await r.json(); + residentGroups.clear(); + (rows||[]).forEach(x => { + const k = normName(x.name); + if (k && !residentGroups.has(k)) residentGroups.set(k, x.group_id); + }); + const dl = document.getElementById('fbResidentList'); + if (dl) dl.innerHTML = (rows||[]).map(x => `'; + if (fsel) fsel.innerHTML = ''; + (list||[]).filter(x=>!x.deleted_at).forEach(g => { + const opt = document.createElement('option'); opt.value = g.id; opt.textContent = g.name; sel && sel.appendChild(opt.cloneNode(true)); fsel && fsel.appendChild(opt); + }); + groupsLoaded = true; + } + } catch (e) {} + } + + async function loadAiProfiles() { + try { + const r = await fetch(`${API}/api/ai/profiles`, { headers: hdr() }); + if (r.ok) { + const d = await r.json(); + const sel = document.getElementById('fbModel'); + if (!sel) return; + sel.innerHTML = ''; + const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sel.appendChild(nativeOpt); + (d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sel.appendChild(o); }); + if (d.active) sel.value = d.active; + } + } catch (e) {} + } + + async function fetchShorts(q) { + if (!q || q.length < 2) { shortsListCache = []; renderShorts([]); return; } + try { + const r = await fetch(`${API}/api/shorts?search=${encodeURIComponent(q)}&limit=50&deleted=0`, { headers: hdr() }); + if (r.ok) { + const j = await r.json(); + shortsListCache = j.items || []; renderShorts(shortsListCache); + } + } catch (e) {} + } + + function renderShorts(items) { + const wrap = document.getElementById('fbShortsWrap'); + const list = document.getElementById('fbShortsList'); + if (!list || !wrap) return; + if (!items.length) { wrap.style.display = 'none'; list.innerHTML = ''; return; } + wrap.style.display = ''; + list.innerHTML = items.map(s => { + const text = (s.message||'').slice(0,120); + const topic = s.topic || '-'; + return `
`; + }).join(''); + } + + function resetForm() { + document.getElementById('fbId').value = ''; + document.getElementById('fbDate').value = todayIso(); + document.getElementById('fbGroup').value = ''; + document.getElementById('fbResident').value = ''; + document.getElementById('fbTopic').value = ''; + document.getElementById('fbPastCheck').checked = false; + document.getElementById('fbPastReviewWrap').style.display = 'none'; + document.getElementById('fbPastReview').value = ''; + document.getElementById('fbShortsWrap').style.display = 'none'; + document.getElementById('fbShortsList').innerHTML = ''; + document.getElementById('fbText').value = ''; + lastFbAutoText = ''; + document.getElementById('fbTextHint').textContent = '0 / 5000'; + document.getElementById('fbAiCheck').checked = true; + document.getElementById('fbAiStatus').hidden = true; + document.getElementById('fbModalTitle').textContent = 'Добавить отзыв'; + document.getElementById('fbSubmit').textContent = 'Отправить в очередь'; + } + + async function loadFeedback(id) { + try { + const r = await fetch(`${API}/api/feedbacks/${id}`, { headers: hdr() }); + if (r.ok) { + const f = await r.json(); + document.getElementById('fbId').value = f.id; + document.getElementById('fbDate').value = f.feedback_date || todayIso(); + document.getElementById('fbGroup').value = f.group_id || ''; + document.getElementById('fbResident').value = f.resident || ''; + document.getElementById('fbTopic').value = f.topic || ''; + if (f.past_review) { + document.getElementById('fbPastCheck').checked = true; + document.getElementById('fbPastReviewWrap').style.display = ''; + document.getElementById('fbPastReview').value = f.past_review; + } + document.getElementById('fbText').value = f.text || ''; + lastFbAutoText = ''; + document.getElementById('fbTextHint').textContent = `${(f.text||'').length} / 5000`; + document.getElementById('fbAiCheck').checked = (f.ai_status === 'pending' || f.ai_status === 'processing' || f.ai_status === 'none') ? true : false; + document.getElementById('fbModalTitle').textContent = 'Редактировать отзыв'; + document.getElementById('fbSubmit').textContent = 'Сохранить'; + if (f.short_message_ids && f.short_message_ids.length) { + try { + const list = []; + for (const sid of f.short_message_ids.slice(0,20)) { + const sr = await fetch(`${API}/api/shorts/${sid}`, { headers: hdr() }); + if (sr.ok) list.push(await sr.json()); + } + renderShorts(list); + } catch (e) {} + } + } + } catch (e) {} + } + + async function loadFeedbacks() { + const el = document.getElementById('feedbackList'); + const st = document.getElementById('feedbackState'); + if (!el) return; + el.innerHTML = '
Загрузка…
'; + const q = new URLSearchParams(); + q.set('limit', PAGE_SIZE); q.set('offset', (page-1)*PAGE_SIZE); + const s = document.getElementById('fbSearch').value.trim(); if (s) q.set('search', s); + const g = document.getElementById('fbGroupFilter').value; if (g) q.set('group_id', g); + const stv = document.getElementById('fbStatusFilter').value; if (stv) q.set('status', stv); + const df = document.getElementById('fbDateFrom').value; if (df) q.set('date_from', df); + const dt = document.getElementById('fbDateTo').value; if (dt) q.set('date_to', dt); + if (document.getElementById('fbDeletedToggle').checked) q.set('deleted', '1'); + try { + const r = await fetch(`${API}/api/feedbacks?${q.toString()}`, { headers: hdr() }); + if (r.ok) { + const j = await r.json(); + feedbacks = j.items || []; + total = j.total || 0; + el.innerHTML = feedbacks.length ? feedbacks.map(fbItemHtml).join('') : '
Список пуст
'; + if (st) st.textContent = `Всего: ${total}`; + renderPager(total, page, PAGE_SIZE, p => { page = p; loadFeedbacks(); }); + } else { el.innerHTML = '
Ошибка загрузки
'; } + } catch (e) { el.innerHTML = '
Ошибка
'; } + if (window.lucide) lucide.createIcons(); + } + + function renderPager(total, cur, size, onChange) { + const el = document.getElementById('pager'); + if (!el) return; + const pages = Math.max(1, Math.ceil(total/size)); + if (pages <= 1) { el.innerHTML = ''; return; } + let html = ''; + for (let i=1;i<=pages;i++) { + html += ``; + } + el.innerHTML = html; + el.querySelectorAll('[data-page]').forEach(b => b.addEventListener('click', () => onChange(parseInt(b.dataset.page,10)))); + } + + document.addEventListener('DOMContentLoaded', async () => { + await checkAuth().catch(()=>{}); + buildSidebar('feedback'); + await loadGroups(); + await loadAiProfiles(); + await loadResidentNames(); + loadTopics(); + loadFeedbacks(); + connectFeedbackLive(); + document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({})); + document.getElementById('fbCancel').addEventListener('click', closeModal); + document.getElementById('feedbackModal').addEventListener('click', e => { if (e.target.id === 'feedbackModal') closeModal(); }); + document.getElementById('fbForm').addEventListener('submit', async e => { + e.preventDefault(); + const id = document.getElementById('fbId').value; + const body = { + feedback_date: document.getElementById('fbDate').value, + group_id: parseInt(document.getElementById('fbGroup').value,10), + resident: document.getElementById('fbResident').value.trim(), + topic: document.getElementById('fbTopic').value.trim(), + past_review: document.getElementById('fbPastCheck').checked ? document.getElementById('fbPastReview').value.trim() : null, + text: document.getElementById('fbText').value.trim(), + ai_check: document.getElementById('fbAiCheck').checked, + ai_profile: document.getElementById('fbModel').value || null, + }; + const shortIds = []; + document.querySelectorAll('input[name="fbShort"]:checked').forEach(c => shortIds.push(parseInt(c.value,10))); + body.short_message_ids = shortIds; + const url = id ? `${API}/api/feedbacks/${id}` : `${API}/api/feedbacks`; + const method = id ? 'PUT' : 'POST'; + try { + const r = await fetch(url, { method, headers: {...hdr(), 'Content-Type':'application/json'}, body: JSON.stringify(body) }); + if (r.ok) { closeModal(); loadFeedbacks(); } + } catch (e) {} + }); + document.getElementById('fbResident').addEventListener('input', () => { + clearTimeout(shortsFetchTimer); + const v = document.getElementById('fbResident').value.trim(); + applyResidentGroup(); + shortsFetchTimer = setTimeout(() => fetchShorts(v), 400); + }); + document.getElementById('fbResident').addEventListener('change', applyResidentGroup); + document.getElementById('fbPastCheck').addEventListener('change', e => { + document.getElementById('fbPastReviewWrap').style.display = e.target.checked ? '' : 'none'; + }); + document.getElementById('fbText').addEventListener('input', () => { + document.getElementById('fbTextHint').textContent = `${document.getElementById('fbText').value.length} / 5000`; + }); + document.getElementById('fbTopic').addEventListener('change', applyTopicText); + document.getElementById('feedbackList').addEventListener('click', async e => { + const sentBtn = e.target.closest('[data-sent]'); if (sentBtn) { + const id = parseInt(sentBtn.dataset.sent, 10); + if (!id) return; + const f = feedbacks.find(x => x.id === id); + const next = !(f && f.sent_at); + try { const r = await fetch(`${API}/api/feedbacks/${id}/sent`, { method:'PUT', headers: {...hdr(), 'Content-Type':'application/json'}, body: JSON.stringify({ sent: next }) }); if (r.ok) loadFeedbacks(); } catch (e) {} + return; + } + const edit = e.target.closest('[data-edit]'); if (edit) { openModal({id: parseInt(edit.dataset.edit,10)}); return; } + const del = e.target.closest('[data-del]'); if (del) { + const id = parseInt(del.dataset.id||del.dataset.del,10); + if (!id) return; + if (!confirm('Удалить отзыв?')) return; + try { const r = await fetch(`${API}/api/feedbacks/${id}`, { method:'DELETE', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {} + return; + } + const ret = e.target.closest('[data-restore]'); if (ret) { + const id = parseInt(ret.dataset.id||ret.dataset.restore,10); + if (!id) return; + try { const r = await fetch(`${API}/api/feedbacks/${id}/restore`, { method:'PUT', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {} + return; + } + const retry = e.target.closest('[data-retry]'); if (retry) { + const id = parseInt(retry.dataset.id||retry.dataset.retry,10); + if (!id) return; + try { const r = await fetch(`${API}/api/feedbacks/${id}/ai/retry`, { method:'POST', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {} + return; + } + }); + ['fbSearch','fbGroupFilter','fbStatusFilter','fbDateFrom','fbDateTo'].forEach(id => { + document.getElementById(id).addEventListener('change', () => { page = 1; loadFeedbacks(); }); + if (id === 'fbSearch') document.getElementById(id).addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { page = 1; loadFeedbacks(); }, 350); }); + }); + document.getElementById('fbDeletedToggle').addEventListener('change', () => { page = 1; loadFeedbacks(); }); + if (window.lucide) lucide.createIcons(); + }); + + let liveSource = null, liveReconnectTimer = null; + function connectFeedbackLive() { + if (liveSource) { liveSource.close(); liveSource = null; } + const connect = () => { + const t = sessionStorage.getItem('authToken'); + if (!t) return; + liveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`); + liveSource.addEventListener('feedbacks_changed', () => { page = 1; loadFeedbacks(); }); + liveSource.addEventListener('feedback_status', () => { loadFeedbacks(); }); + liveSource.onerror = () => { + if (liveSource) { liveSource.close(); liveSource = null; } + if (liveReconnectTimer) clearTimeout(liveReconnectTimer); + liveReconnectTimer = setTimeout(connect, 5000); + }; + }; + connect(); + } +})(); diff --git a/server.js b/server.js index 3794529..e556d0e 100644 --- a/server.js +++ b/server.js @@ -6,7 +6,7 @@ const { ipKeyGenerator } = require('express-rate-limit'); const helmet = require('helmet'); const bcrypt = require('bcrypt'); const heicConvert = require('heic-convert'); -const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker } = require('./worker'); +const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, createFeedbackWriter } = require('./worker'); const { createZipWriter, renderStudentReport } = require('./student-report'); const { createStorage, mimeFor } = require('./storage'); @@ -122,6 +122,10 @@ function invalidateLessonReports() { cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:'); broadcastLessonChanged(); } +function invalidateFeedbacks() { + cacheDrop('feedbacks:'); + broadcastFeedbackChanged(); +} function invalidateSessions() { cacheDrop('session:'); } function invalidateApiKeys() { cacheDrop('apikey:'); cacheDrop('rl:apikey'); } @@ -129,6 +133,7 @@ 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'; +const FEEDBACK_WAKE_CHANNEL = 'whatido:wake:feedback'; function createWorkerBus(channel) { return { @@ -155,6 +160,7 @@ function dispatchEvent(payload) { writeFrame('lesson_report_status', { id: payload.id, ai_status: payload.status, + 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, @@ -162,6 +168,10 @@ function dispatchEvent(payload) { }); return; } + if (payload && payload.type === 'feedback_status') { + writeFrame('feedback_status', payload); + return; + } if (payload && (payload.type === 'entries_changed' || payload.type === 'lessons_changed' || payload.type === 'shorts_changed')) { writeFrame(payload.type, { ts: Date.now() }); return; @@ -191,6 +201,11 @@ function broadcastLessonChanged() { .catch(err => console.error('Publish failed:', err.message)); } +function broadcastFeedbackChanged() { + cache.publish(EVENTS_CHANNEL, { type: 'feedbacks_changed', ts: Date.now() }) + .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)); @@ -243,6 +258,7 @@ const NOTIFY_TYPES = { '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 }, + 'feedback.ai.ready': { label: 'Отзыв сгенерирован', hint: 'ИИ сформировал отзыв по результатам занятия', icon: 'message-square-heart', level: 'info', enabled: true, admin: false }, }; function notifySettingKey(type) { @@ -2644,6 +2660,53 @@ async function ensureShortMessagesTable() { await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at)`); } +async function ensureFeedbacksTable() { + await pool.query(`CREATE TABLE IF NOT EXISTS feedbacks ( + id SERIAL PRIMARY KEY, + feedback_date DATE NOT NULL, + group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + resident VARCHAR(200) NOT NULL, + topic VARCHAR(500), + past_review TEXT, + short_message_ids INT[] DEFAULT '{}'::int[], + text TEXT NOT NULL DEFAULT '', + ai_status VARCHAR(20) NOT NULL DEFAULT 'none', + ai_checked_at TIMESTAMPTZ, + ai_error TEXT, + ai_profile VARCHAR(100), + author_id INT REFERENCES users(id) ON DELETE SET NULL, + branch_id INT REFERENCES branches(id) ON DELETE SET NULL, + deleted_at TIMESTAMPTZ, + purge_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + )`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS feedback_date DATE`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS group_id INT`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS resident VARCHAR(200)`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS topic VARCHAR(500)`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS past_review TEXT`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS short_message_ids INT[] DEFAULT '{}'::int[]`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS text TEXT NOT NULL DEFAULT ''`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100)`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT now()`); + await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC)`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at)`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id)`); + await pool.query(`INSERT INTO settings (key,value) VALUES ('feedback_ai_enabled','true') ON CONFLICT (key) DO NOTHING`); + await pool.query(`INSERT INTO settings (key,value) VALUES ('feedback_ai_prompt','${FEEDBACK_AI_DEFAULT_PROMPT.replace(/'/g,"''")}') ON CONFLICT (key) DO NOTHING`); + await pool.query(`INSERT INTO settings (key,value) VALUES ('notify_feedback_ai_ready','false') ON CONFLICT (key) DO NOTHING`); +} + function invalidateShorts() { cache.dropPrefix('shorts:'); cache.publish(EVENTS_CHANNEL, { type: 'shorts_changed' }).catch(() => {}); @@ -2842,6 +2905,144 @@ app.delete('/api/shorts/:id/permanent', requireAuth, requireAdmin, async (req, r } }); +// Feedbacks +async function feedbackById(user, id) { + const { rows } = await pool.query( + `SELECT f.*, g.name AS group_name, g.branch_id AS group_branch_id + FROM feedbacks f + LEFT JOIN groups g ON g.id = f.group_id + WHERE f.id = $1`, + [id] + ); + if (!rows.length) return null; + const row = rows[0]; + if (user.role !== 'admin') { + const bid = row.branch_id ?? row.group_branch_id ?? null; + if (bid != null) { + const ub = user.branch_ids || []; + if (!ub.includes(bid)) return { forbidden: true }; + } else { + return { forbidden: true }; + } + } + row.branch_id = row.branch_id ?? row.group_branch_id ?? null; + return row; +} + +function parseFeedbackDate(s) { + const str = typeof s === 'string' ? s.trim() : ''; + if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) return null; + return str; +} + +app.get('/api/feedbacks', requireAuth, async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100); + const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); + const dateFrom = parseFeedbackDate(req.query.date_from); + const dateTo = parseFeedbackDate(req.query.date_to); + const groupId = parseInt(req.query.group_id, 10); + const status = typeof req.query.status === 'string' ? req.query.status.trim() : ''; + const deleted = req.query.deleted === '1'; + const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; + const resident = typeof req.query.resident === 'string' ? req.query.resident.trim() : ''; + const key = ['feedbacks:list:', scopeKey(req.user), dateFrom || '', dateTo || '', groupId || '', status || '', deleted ? '1' : '', search || '', resident || '', limit || '', offset || ''].join(':'); + const payload = await cacheWrap(key, FEEDBACK_LIST_TTL_MS, async () => { + const params = []; + const cond = []; + if (deleted) cond.push('f.deleted_at IS NOT NULL'); + else { + cond.push('f.deleted_at IS NULL'); + if (status === 'sent') cond.push('f.sent_at IS NOT NULL'); + else cond.push('f.sent_at IS NULL'); + } + if (dateFrom) { params.push(dateFrom); cond.push(`f.feedback_date >= $${params.length}`); } + if (dateTo) { params.push(dateTo); cond.push(`f.feedback_date <= $${params.length}`); } + if (Number.isInteger(groupId) && groupId > 0) { params.push(groupId); cond.push(`f.group_id = $${params.length}`); } + if (resident) { params.push(`%${resident}%`); cond.push(`f.resident ILIKE $${params.length}`); } + if (status && status !== 'sent') { params.push(status); cond.push(`f.ai_status = $${params.length}`); } + if (search) { + params.push(`%${search}%`); + cond.push(`(f.resident ILIKE $${params.length} OR f.topic ILIKE $${params.length} OR f.text ILIKE $${params.length})`); + } + const bs = branchScope(req.user); + if (bs.ids) { params.push(bs.ids); cond.push(`f.branch_id = ANY($${params.length}::int[])`); } + const where = cond.length ? `WHERE ${cond.join(' AND ')}` : ''; + const countParams = params.slice(); + params.push(limit, offset); + const count = await pool.query(`SELECT COUNT(*) FROM feedbacks f ${where}`, countParams); + const items = await pool.query( + `SELECT f.id, f.feedback_date, f.group_id, f.resident, f.topic, f.past_review, f.short_message_ids, f.text, f.ai_status, f.ai_checked_at, f.ai_error, f.ai_profile, f.sent_at, f.author_id, f.branch_id, f.deleted_at, f.purge_at, f.created_at, f.updated_at, g.name AS group_name + FROM feedbacks f + LEFT JOIN groups g ON g.id = f.group_id + ${where} + ORDER BY f.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`, + params + ); + return { items: items.rows, total: parseInt(count.rows[0].count, 10) }; + }); + res.json(payload); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal error' }); + } +}); + +app.get('/api/feedbacks/:id', requireAuth, async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + const fb = await feedbackById(req.user, id); + if (!fb) return res.status(404).json({ error: 'Not found' }); + if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' }); + res.json(fb); +}); + +app.post('/api/feedbacks', requireAuth, async (req, res) => { + const feedbackDate = parseFeedbackDate(req.body?.feedback_date); + if (!feedbackDate) return res.status(400).json({ error: 'Некорректная дата' }); + const resident = reqStr(req.body?.resident, FEEDBACK_RESIDENT_MAX); + const topic = optStr(req.body?.topic, FEEDBACK_TOPIC_MAX); + const text = optStr(req.body?.text, FEEDBACK_TEXT_MAX); + const groupId = parseInt(req.body?.group_id, 10); + if (!Number.isInteger(groupId) || groupId < 1) return res.status(400).json({ error: 'Некорректная группа' }); + const pastReview = optStr(req.body?.past_review, 2000); + let shortIds = Array.isArray(req.body?.short_message_ids) ? req.body.short_message_ids.map(Number).filter(v => Number.isInteger(v) && v > 0) : []; + shortIds = [...new Set(shortIds)]; + const aiCheck = !!req.body?.ai_check; + const aiProfile = optStr(req.body?.ai_profile, 100); + if (req.user.role !== 'admin') { + const ok = await groupBelongsToBranches(req.user, groupId); + if (!ok) return res.status(403).json({ error: 'Нет доступа к этой группе' }); + } + const g = await pool.query('SELECT branch_id FROM groups WHERE id = $1', [groupId]); + if (!g.rows.length) return res.status(400).json({ error: 'Группа не найдена' }); + const branchId = g.rows[0].branch_id; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const insert = await client.query( + `INSERT INTO feedbacks (feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_profile, author_id, branch_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, + [feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', aiCheck ? 'pending' : 'none', aiProfile || null, req.user.id, branchId] + ); + const fb = insert.rows[0]; + if (shortIds.length) { + await client.query(`UPDATE short_messages SET used = true, used_at = CURRENT_DATE WHERE id = ANY($1::int[]) AND deleted_at IS NULL`, [shortIds]); + } + await client.query('COMMIT'); + await logAudit(req, 'feedback.create', { id: fb.id, group_id: groupId, resident, ai_check: aiCheck, ai_status: fb.ai_status, short_count: shortIds.length }); + invalidateFeedbacks(); + invalidateShorts(); + if (aiCheck) wakeFeedbackWorker(); + res.status(201).json(fb); + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + throw e; + } finally { + client.release(); + } +}); + app.post('/api/shorts/ai-correct', requireAuth, aiLimiter, async (req, res) => { try { const text = reqStr(req.body?.text, 5000); @@ -2992,6 +3193,122 @@ app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => { } }); +app.put('/api/feedbacks/:id', requireAuth, async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + const fb = await feedbackById(req.user, id); + if (!fb) return res.status(404).json({ error: 'Not found' }); + if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' }); + const feedbackDate = parseFeedbackDate(req.body?.feedback_date); + if (!feedbackDate) return res.status(400).json({ error: 'Некорректная дата' }); + const resident = reqStr(req.body?.resident, FEEDBACK_RESIDENT_MAX); + const topic = optStr(req.body?.topic, FEEDBACK_TOPIC_MAX); + const text = optStr(req.body?.text, FEEDBACK_TEXT_MAX); + const groupId = parseInt(req.body?.group_id, 10); + if (!Number.isInteger(groupId) || groupId < 1) return res.status(400).json({ error: 'Некорректная группа' }); + const pastReview = optStr(req.body?.past_review, 2000); + let shortIds = Array.isArray(req.body?.short_message_ids) ? req.body.short_message_ids.map(Number).filter(v => Number.isInteger(v) && v > 0) : []; + shortIds = [...new Set(shortIds)]; + if (req.user.role !== 'admin') { + const ok = await groupBelongsToBranches(req.user, groupId); + if (!ok) return res.status(403).json({ error: 'Нет доступа к этой группе' }); + } + await pool.query( + `UPDATE feedbacks SET feedback_date=$1, group_id=$2, resident=$3, topic=$4, past_review=$5, short_message_ids=$6, text=$7, updated_at=now() WHERE id=$8`, + [feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', id] + ); + invalidateFeedbacks(); + res.json({ ok: true }); +}); + +app.delete('/api/feedbacks/:id', requireAuth, async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + const fb = await feedbackById(req.user, id); + if (!fb) return res.status(404).json({ error: 'Not found' }); + if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' }); + const d = await pool.query('SELECT COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) AS d'); + const days = d.rows[0]?.d || 30; + await pool.query('UPDATE feedbacks SET deleted_at=now(), purge_at = now() + ($1 || \' days\')::interval WHERE id=$2 AND deleted_at IS NULL', [days, id]); + invalidateFeedbacks(); + res.json({ ok: true }); +}); + +app.put('/api/feedbacks/:id/restore', requireAuth, requireAdmin, async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + await pool.query('UPDATE feedbacks SET deleted_at=NULL, purge_at=NULL WHERE id=$1 AND deleted_at IS NOT NULL', [id]); + invalidateFeedbacks(); + res.json({ ok: true }); +}); + +app.delete('/api/feedbacks/:id/permanent', requireAuth, requireAdmin, async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + await pool.query('UPDATE feedbacks SET purge_at = now() + (COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) || \' days\')::interval WHERE id=$1 AND deleted_at IS NOT NULL', [id]); + invalidateFeedbacks(); + res.json({ ok: true }); +}); + +app.put('/api/feedbacks/:id/sent', requireAuth, async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + const fb = await feedbackById(req.user, id); + if (!fb) return res.status(404).json({ error: 'Not found' }); + if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' }); + const sent = typeof req.body?.sent === 'boolean' ? req.body.sent : null; + if (sent === null) return res.status(400).json({ error: 'Некорректный статус' }); + const shortIds = Array.isArray(fb.short_message_ids) ? fb.short_message_ids : []; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('UPDATE feedbacks SET sent_at = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL', [sent ? new Date() : null, id]); + if (shortIds.length) { + if (sent) { + await client.query('UPDATE short_messages SET used = true, used_at = CURRENT_DATE WHERE id = ANY($1::int[]) AND deleted_at IS NULL', [shortIds]); + } else { + await client.query(`UPDATE short_messages sm SET used = false, used_at = NULL + WHERE sm.id = ANY($1::int[]) AND sm.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM feedbacks f WHERE f.deleted_at IS NULL AND f.id <> $2 AND sm.id = ANY(f.short_message_ids))`, [shortIds, id]); + } + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + throw e; + } finally { + client.release(); + } + await logAudit(req, 'feedback.sent', { id, sent, short_count: shortIds.length }); + invalidateFeedbacks(); + invalidateShorts(); + res.json({ ok: true, sent }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal error' }); + } +}); + +app.post('/api/feedbacks/:id/ai/retry', requireAuth, async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + const fb = await feedbackById(req.user, id); + if (!fb) return res.status(404).json({ error: 'Not found' }); + if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' }); + await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, [id]); + wakeFeedbackWorker(); + invalidateFeedbacks(); + res.json({ ok: true }); +}); + +app.post('/api/feedbacks/requeue-failed', requireAuth, requireAdmin, async (req, res) => { + await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL, updated_at=now() WHERE ai_status='error' AND deleted_at IS NULL`); + wakeFeedbackWorker(); + invalidateFeedbacks(); + res.json({ ok: true }); +}); + app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => { if (req.params.id === String(req.user.id)) { return res.status(400).json({ error: 'Нельзя удалить самого себя' }); @@ -3363,11 +3680,16 @@ const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор т let entryAutoChecker = null; let photoWorker = null; let lessonAiChecker = null; +let feedbackWorker = null; function wakeLessonAiWorker() { if (lessonAiChecker) lessonAiChecker.notify(); } +function wakeFeedbackWorker() { + if (feedbackWorker) feedbackWorker.notify(); +} + async function onLessonAiDone({ row, status, text, aiText, error }) { invalidateLessonReports(); cache.publish(EVENTS_CHANNEL, { @@ -3473,6 +3795,28 @@ async function aiComplete(systemPrompt, userText, maxTokensOverride, profileOver } } +async function onFeedbackDone({ row, status, text, aiText, error }) { + invalidateFeedbacks(); + cache.publish(EVENTS_CHANNEL, { + type: 'feedback_status', + id: row.id, + ai_status: status, + status, + error: error || null, + text, + ts: Date.now() + }).catch(() => {}); + if (status === 'done') { + try { + const lenBefore = row.text ? row.text.length : 0; + const lenAfter = (aiText || '').length; + logAudit(null, 'feedback.ai.generate', { id: row.id, len_before: lenBefore, len_after: lenAfter }).catch(() => {}); + const branchId = row.branch_id ?? null; + pushNotification('feedback.ai.ready', { id: row.id, resident: row.resident }, branchId).catch(() => {}); + } catch (e) {} + } +} + async function aiCorrectText(text, profileOverride) { try { const systemPrompt = await getAiPrompt(); @@ -3759,6 +4103,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req 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 feedbacks'); await client.query('DELETE FROM entries'); await client.query('DELETE FROM modules'); await client.query('DELETE FROM students'); @@ -3919,6 +4264,15 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [x.id, x.resident, x.lesson_date, x.topic, x.message, x.used, x.used_at, okUser ? x.author_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at] ); } + for (const x of (ndata.feedbacks || [])) { + const okGroup = x.group_id == null || (await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id])).rowCount; + const okUser = x.author_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.author_id])).rowCount; + const okBranch = x.branch_id == null || (await client.query('SELECT 1 FROM branches WHERE id = $1', [x.branch_id])).rowCount; + await client.query( + 'INSERT INTO feedbacks (id, feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_checked_at, ai_error, ai_profile, author_id, branch_id, deleted_at, purge_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)', + [x.id, x.feedback_date, okGroup ? x.group_id : null, x.resident, x.topic, x.past_review, Array.isArray(x.short_message_ids) ? x.short_message_ids : [], x.text || '', x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.ai_profile, okUser ? x.author_id : null, okBranch ? x.branch_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at] + ); + } for (const x of ndata.audit_log) { const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount; await client.query( @@ -3940,7 +4294,9 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } for (const tbl of BACKUP_SEQUENCE_TABLES) { 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]); + try { + await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]); + } catch (e) {} } await client.query('COMMIT'); } catch (e) { @@ -4889,6 +5245,41 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => { // --- Lesson reports (отчёт о занятии) --- const LESSON_REPORT_LIST_TTL_MS = 30 * 1000; const LESSON_AI_VERSION_LIMIT = 50; +const LESSON_AI_TIMEOUT_MS = 120000; + +const FEEDBACK_AI_VERSION_LIMIT = 50; +const FEEDBACK_AI_TIMEOUT_MS = 120000; +const FEEDBACK_LIST_TTL_MS = 30 * 1000; +const FEEDBACK_TEXT_MAX = 5000; +const FEEDBACK_TOPIC_MAX = 500; +const FEEDBACK_RESIDENT_MAX = 200; +const FEEDBACK_AI_DEFAULT_PROMPT = [ + 'Ты — помощник по написанию коротких деловых отзывов тьютора детской IT-школы.', + '', + 'ЗАДАЧА: Сформируй короткий, ёмкий, деловой отзыв по итогам занятия. По стилю он должен быть близок к «короткому сообщению для обратной связи»: 1–3 предложения, без воды, конкретно по делу.', + '', + 'КОНТЕКСТ, КОТОРЫЙ ТЫ ПОЛУЧИШЬ:', + '- Группа: ...', + '- Дата занятия: ...', + '- Резидент: ...', + '- Тема занятия: ...', + '- Позиция темы: ... (если есть)', + '- Прошлый отзыв: ... (если есть)', + '- Короткие сообщения (выбраны): список в формате "дата – тема – сообщение"', + '', + 'ПРАВИЛА:', + '- Пиши только отзыв. Без заголовков, списков, markdown, подписей и пояснений.', + '- Не упоминай группу, дату, время занятия в тексте отзыва.', + '- Не начинай со слов «Сегодня», «Вчера», «На занятии», «В ходе занятия», если они избыточны.', + '- Опирайся на факты из контекста: тема занятия, выбранные короткие сообщения и прошлый отзыв (если есть).', + '- Сохраняй деловой, спокойный тон. Без оценок типа «отлично», «замечательно», избегай шаблонных «молодец».', + '- Максимально кратко: 1–2 предложения. Если хватает одного — лучше одно.', + '- Не оборачивай ответ в кавычки.', + '- Не придумывай факты, которых нет в контексте.', + '', + 'ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.' +].join('\n'); + const LESSON_AI_DEFAULT_PROMPT = [ 'Ты — редактор сообщений тьютора детской IT-школы.', '', @@ -5038,6 +5429,45 @@ app.get('/api/lesson-reports', requireAuth, async (req, res) => { res.json({ items: rows, total }); }); +app.get('/api/lesson-reports/topics', requireAuth, async (req, res) => { + const bw = branchWhere(req.user, 'g'); + const { rows } = await pool.query( + `SELECT DISTINCT lr.topic FROM lesson_reports lr + JOIN groups g ON g.id = lr.group_id + WHERE lr.topic IS NOT NULL AND lr.topic <> ''${bw.where} + ORDER BY lr.topic`, + bw.params + ); + res.json(rows.map(r => r.topic)); +}); + +app.get('/api/lesson-reports/by-topic', requireAuth, async (req, res) => { + try { + const topic = typeof req.query.topic === 'string' ? req.query.topic.trim() : ''; + if (!topic) return res.status(400).json({ error: 'Укажите тему' }); + const cond = ['lr.topic = $1']; + const params = [topic]; + const s = branchScope(req.user); + if (!s.admin) { + if (!s.ids.length) cond.push('1 = 0'); + else { params.push(s.ids); cond.push(`g.branch_id = ANY($${params.length}::int[])`); } + } + const { rows } = await pool.query( + `SELECT lr.id, lr.group_id, lr.lesson_date, lr.topic, lr.text, g.name AS group_name + FROM lesson_reports lr + JOIN groups g ON g.id = lr.group_id + WHERE ${cond.join(' AND ')} + ORDER BY lr.lesson_date DESC, lr.id DESC + LIMIT 1`, + params + ); + res.json({ report: rows.length ? rows[0] : null }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal error' }); + } +}); + app.get('/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 }); @@ -5254,6 +5684,19 @@ app.get('/api/students/names', requireAuth, async (req, res) => { res.json(rows.map(r => r.name)); }); +app.get('/api/students/names-with-groups', requireAuth, async (req, res) => { + const bw = branchWhere(req.user, 'g'); + const { rows } = await pool.query( + `SELECT DISTINCT ON (e.student_name) e.student_name AS name, e.group_id, g.name AS group_name + FROM entries e + JOIN groups g ON g.id = e.group_id + WHERE e.student_name IS NOT NULL AND e.student_name <> ''${bw.where} + ORDER BY e.student_name, e.created_at DESC NULLS LAST, e.id DESC`, + bw.params + ); + res.json(rows); +}); + app.post('/api/students', requireAuth, async (req, res) => { const { name, group_id } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); @@ -8755,6 +9198,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); } try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); } try { await ensureShortMessagesTable(); } catch (err) { console.error('Short messages table:', err); } +try { await ensureFeedbacksTable(); } catch (err) { console.error('Feedbacks table:', err); } try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); } setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref(); try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); } @@ -8805,4 +9249,15 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { }); lessonAiChecker.start(); console.log('Lesson report AI worker started'); + feedbackWorker = createFeedbackWriter({ + pool, + getSetting, + logAudit, + aiUrl: AI_URL, + defaultPrompt: FEEDBACK_AI_DEFAULT_PROMPT, + bus: createWorkerBus(FEEDBACK_WAKE_CHANNEL), + onDone: onFeedbackDone, + }); + feedbackWorker.start(); + console.log('Feedback AI worker started'); })(); diff --git a/worker.js b/worker.js index 4051e36..1b1ae1a 100644 --- a/worker.js +++ b/worker.js @@ -1051,4 +1051,302 @@ async function loop() { return { start, stop, notify, getInfo }; } -module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, lessonTopicPosition }; +function createFeedbackWriter(opts) { + const pool = opts.pool; + const getSetting = opts.getSetting; + const logAudit = opts.logAudit; + const aiUrl = (opts.aiUrl || process.env.AI_URL || 'http://text-corrector:8080'); + const defaultPrompt = opts.defaultPrompt || ''; + const bus = opts.bus; + const onDone = opts.onDone || (() => {}); + + const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf'; + const TIMEOUT_MS = opts.timeoutMs || parseInt(process.env.FEEDBACK_AI_TIMEOUT_MS || '120000', 10) || 120000; + const MAX_ATTEMPTS = 3; + const IDLE_MIN_MS = 500; + const IDLE_MAX_MS = 30000; + const MIN_CHARS = 1; + const MAX_INPUT_CHARS = 6000; + const TEXT_MAX = 5000; + + let running = false; + let timer = null; + let stopped = false; + let idleMs = IDLE_MIN_MS; + let inFlight = false; + + function notify() { + if (!running || inFlight) return; + clearTimeout(timer); + timer = setTimeout(tick, 50); + } + + if (bus && typeof bus.subscribe === 'function') { + try { bus.subscribe(notify); } catch (e) {} + } + + async function getSettingValue(key, def) { + try { + const v = await getSetting(key, def); + return v; + } catch (e) { + return def; + } + } + + async function resolveActiveProfile(profileId) { + try { + const active = await getSettingValue('ai_active_profile', 'native'); + const profilesRaw = await getSettingValue('ai_profiles', '[]'); + let profiles = []; + try { profiles = JSON.parse(profilesRaw); } catch (e) {} + if (profileId) { + const p = profiles.find(x => String(x.id) === String(profileId)); + if (p) return { type: 'profile', profile: p }; + } + if (String(active) !== 'native') { + const p = profiles.find(x => String(x.id) === String(active)); + if (p) return { type: 'profile', profile: p }; + } + return { type: 'native', model: AI_MODEL, baseUrl: aiUrl }; + } catch (e) { + return { type: 'native', model: AI_MODEL, baseUrl: aiUrl }; + } + } + + function normalizeOpenAiBase(baseUrl) { + const b = (baseUrl || '').replace(/\/$/, ''); + return b.includes('/v1') ? b : `${b}/v1`; + } + + async function callModel(systemPrompt, userText, profileId) { + const text = String(userText || ''); + if (text.length > MAX_INPUT_CHARS) { + return text.slice(0, TEXT_MAX); + } + const resolved = await resolveActiveProfile(profileId); + const controller = new AbortController(); + const timerAbort = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + let url, headers, body; + if (resolved.type === 'profile') { + const p = resolved.profile; + const base = normalizeOpenAiBase(p.base_url || aiUrl); + url = `${base}/chat/completions`; + headers = { 'Content-Type': 'application/json' }; + if (p.api_key) headers.Authorization = `Bearer ${p.api_key}`; + body = { + model: p.model || AI_MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: text } + ], + temperature: 0.1, + max_tokens: p.max_tokens || Math.min(2048, Math.max(256, text.length * 2)) + }; + } else { + const base = normalizeOpenAiBase(resolved.baseUrl || aiUrl); + url = `${base}/chat/completions`; + headers = { 'Content-Type': 'application/json' }; + body = { + model: resolved.model || AI_MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: text } + ], + temperature: 0.1, + max_tokens: Math.min(2048, Math.max(256, text.length * 2)) + }; + } + const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal }); + if (!res.ok) throw new Error(`AI service error: ${res.status}`); + const data = await res.json(); + return data.choices?.[0]?.message?.content?.trim() || ''; + } finally { + clearTimeout(timerAbort); + } + } + + function normalizeOutput(s) { + let out = String(s || '').trim(); + if (out.startsWith('```') && out.endsWith('```')) { + out = out.slice(3, -3).trim(); + } + out = out.replace(/^(Итоговый текст:|Отзыв:)\s*/i, ''); + out = out.replace(/^"|"$/g, ''); + out = out.replace(/\n{3,}/g, '\n\n'); + return out; + } + + async function isEnabled() { + try { + const v = await getSettingValue('feedback_ai_enabled', 'true'); + return String(v) !== 'false'; + } catch (e) { + return true; + } + } + + async function claimNext() { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const q = `SELECT f.*, g.name AS group_name, g.branch_id AS group_branch_id + FROM feedbacks f + LEFT JOIN groups g ON g.id = f.group_id + WHERE f.ai_status = 'pending' AND f.deleted_at IS NULL + ORDER BY f.id ASC + FOR UPDATE OF f SKIP LOCKED + LIMIT 1`; + const r = await client.query(q); + if (r.rows.length === 0) { + await client.query('ROLLBACK'); + return null; + } + const row = r.rows[0]; + await client.query(`UPDATE feedbacks SET ai_status='processing', updated_at=now() WHERE id=$1`, [row.id]); + await client.query('COMMIT'); + row.branch_id = row.branch_id ?? row.group_branch_id ?? null; + return row; + } catch (e) { + try { await client.query('ROLLBACK'); } catch (e2) {} + throw e; + } finally { + client.release(); + } + } + + async function processOne(row) { + if (row.text && String(row.text).trim().length >= MIN_CHARS) { + // allow overwrite only if empty? keep as done? but generate on request + } + let context = ''; + context += `Группа: ${row.group_name || ''}\n`; + context += `Дата занятия: ${row.feedback_date || ''}\n`; + context += `Резидент: ${row.resident || ''}\n`; + context += `Тема занятия: ${row.topic || ''}\n`; + context += `Прошлый отзыв: ${row.past_review || 'нет'}\n`; + let shorts = []; + try { + const ids = Array.isArray(row.short_message_ids) ? row.short_message_ids : []; + if (ids.length) { + const sr = await pool.query(`SELECT resident, lesson_date, topic, message FROM short_messages WHERE id = ANY($1::int[]) AND deleted_at IS NULL ORDER BY id`, [ids]); + shorts = sr.rows; + } + } catch (e) {} + if (shorts.length) { + context += 'Короткие сообщения (выбраны):\n'; + for (const s of shorts) { + context += `- ${s.lesson_date || ''} – ${s.topic || '-'} – ${s.message || ''}\n`; + } + } + const systemPrompt = defaultPrompt + (context ? '\n\nКОНТЕКСТ:\n' + context : ''); + let out = await callModel(systemPrompt, row.text && row.text.trim() ? row.text : (context || 'Сформируй отзыв по контексту'), row.ai_profile || null); + let norm = normalizeOutput(out); + if (!norm) return { status: 'error', error: 'Пустой ответ от модели' }; + if (norm.length > TEXT_MAX) norm = norm.slice(0, TEXT_MAX); + const oldText = (row.text || '').trim(); + if (oldText && norm.trim() === oldText) { + return { status: 'skipped', text: oldText }; + } + return { status: 'done', text: norm, aiText: norm }; + } + + async function finish(row, res) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const sets = []; + const params = []; + let i = 1; + sets.push(`ai_status=$${i++}`); params.push(res.status); + sets.push(`ai_checked_at=now()`); + if (res.error) { sets.push(`ai_error=$${i++}`); params.push(res.error); } else { sets.push(`ai_error=NULL`); } + if (res.status === 'done' && res.text) { sets.push(`text=$${i++}`); params.push(res.text); } + sets.push(`updated_at=now()`); + params.push(row.id); + await client.query(`UPDATE feedbacks SET ${sets.join(', ')} WHERE id=$${i}`, params); + await client.query('COMMIT'); + } catch (e) { + try { await client.query('ROLLBACK'); } catch (e2) {} + throw e; + } finally { + client.release(); + } + try { onDone({ row, status: res.status, text: res.text || row.text, aiText: res.aiText, error: res.error }); } catch (e) {} + } + + async function tick() { + if (stopped || !running || inFlight) return; + const enabled = await isEnabled(); + if (!enabled) { + idleMs = Math.min(idleMs * 2, IDLE_MAX_MS); + timer = setTimeout(tick, idleMs); + return; + } + inFlight = true; + try { + let attempts = 0; + while (attempts < MAX_ATTEMPTS) { + attempts++; + const row = await claimNext(); + if (!row) break; + let res; + try { + res = await processOne(row); + } catch (e) { + res = { status: 'error', error: e.message }; + } + if (res.status === 'error' && attempts < MAX_ATTEMPTS) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(`UPDATE feedbacks SET ai_status='pending', updated_at=now(), ai_error=$2 WHERE id=$1`, [row.id, res.error || 'error']); + await client.query('COMMIT'); + } catch (e2) { + try { await client.query('ROLLBACK'); } catch (e3) {} + } finally { + client.release(); + } + idleMs = IDLE_MIN_MS; + continue; + } + await finish(row, res); + idleMs = IDLE_MIN_MS; + } + } catch (e) { + idleMs = Math.min(idleMs * 2, IDLE_MAX_MS); + } finally { + inFlight = false; + timer = setTimeout(tick, idleMs); + } + } + + async function resetStale() { + try { + await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL WHERE ai_status='processing'`); + } catch (e) {} + } + + function start() { + if (running) return; + running = true; + stopped = false; + idleMs = IDLE_MIN_MS; + resetStale().finally(() => { tick(); }); + } + + function stop() { + running = false; + stopped = true; + if (timer) { clearTimeout(timer); timer = null; } + } + + function getInfo() { + return { running, inFlight, idleMs }; + } + + return { start, stop, notify, getInfo }; +} + +module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, createFeedbackWriter, lessonTopicPosition };