Add feedback system functionality

- Implement feedback management system in admin interface
- Add feedback processing and AI-based features
- Update user interface with new feedback components
- Modify server-side API endpoints for feedback handling
- Enhance worker.js for background feedback processing
- Update deployment configuration for feedback components

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
dev
2026-10-06 13:10:14 +03:00
co-authored by openhands
parent a3169648f3
commit 98f24faaaf
13 changed files with 1717 additions and 31 deletions
+2
View File
@@ -391,6 +391,8 @@ guards against.
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование | | `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
| `public/chat.html` | Чат тьютор↔админ: список диалогов, лента сообщений, вложения (Ctrl+V) | | `public/chat.html` | Чат тьютор↔админ: список диалогов, лента сообщений, вложения (Ctrl+V) |
| `public/js/chat.js` | Логика чата: загрузка треда/сообщений, отправка, вставка файлов из буфера | | `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/backup.sh` | Host-level backup script (DB dump + storage export) |
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) | | `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
+211
View File
@@ -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','<FEEDBACK_AI_DEFAULT_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`
+24 -3
View File
@@ -8,13 +8,13 @@ const BACKUP_TABLES = [
'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs',
'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications',
'notification_reads', 'banned_ips', 'chat_threads', 'chat_messages', 'chat_attachments', 'notification_reads', 'banned_ips', 'chat_threads', 'chat_messages', 'chat_attachments',
'short_messages', 'short_messages', 'feedbacks',
]; ];
const BACKUP_SEQUENCE_TABLES = [ const BACKUP_SEQUENCE_TABLES = [
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos',
'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports',
'lesson_report_versions', 'audit_log', 'notifications', 'chat_threads', 'chat_messages', 'lesson_report_versions', 'audit_log', 'notifications', 'chat_threads', 'chat_messages',
'chat_attachments', 'short_messages', 'chat_attachments', 'short_messages', 'feedbacks',
]; ];
function isSupportedBackupVersion(data) { function isSupportedBackupVersion(data) {
@@ -516,7 +516,28 @@ function normalizeRestoreData(data) {
created_at: optTs(x.created_at), created_at: optTs(x.created_at),
updated_at: optTs(x.updated_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 = { module.exports = {
+53 -1
View File
@@ -460,6 +460,32 @@ INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — реда
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
ON CONFLICT (key) DO NOTHING; 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 ( CREATE TABLE IF NOT EXISTS short_messages (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -476,7 +502,33 @@ CREATE TABLE IF NOT EXISTS short_messages (
updated_at TIMESTAMPTZ DEFAULT now() 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_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; 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);
+101 -20
View File
@@ -456,31 +456,32 @@ INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — реда
ГЛАВНОЕ ПРАВИЛО: ГЛАВНОЕ ПРАВИЛО:
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.') ON CONFLICT (key) DO NOTHING; Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.') ON CONFLICT (key) DO NOTHING;
UPDATE settings UPDATE settings
SET value = 'Ты — редактор сообщений тьютора детской IT-школы. 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: научились выбирать фон, добавлять и изменять персонажей…». «На занятии ребята продолжили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
Тот же исходный текст, но «Позиция темы: последнее занятие модуля (3 из 3)»: Тот же исходный текст, но «Позиция темы: последнее занятие модуля (3 из 3)»:
«На занятии ребята завершили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…». «На занятии ребята завершили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
ГЛАВНОЕ ПРАВИЛО: ГЛАВНОЕ ПРАВИЛО:
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.' Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.'
WHERE key = 'lesson_ai_prompt' AND value IN ( 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; 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; 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;
+6
View File
@@ -216,9 +216,14 @@ services:
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan} PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0} PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92} 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 TZ: Europe/Moscow
volumes: volumes:
- photo-ai-models:/models - photo-ai-models:/models
- photo-ai-cache:/root/.cache
ports: ports:
- "127.0.0.1:8081:8080" - "127.0.0.1:8081:8080"
healthcheck: healthcheck:
@@ -232,5 +237,6 @@ services:
volumes: volumes:
pgdata: pgdata:
photo-ai-models: photo-ai-models:
photo-ai-cache:
redis-data: redis-data:
s3-data: s3-data:
+16 -4
View File
@@ -6,14 +6,26 @@ ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
WORKDIR /app 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 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 && \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \ 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 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) || \ RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
+4
View File
@@ -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{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 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{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{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)} .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} #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-done{background:rgba(22,163,74,.12);color:#16a34a}
.ai-badge-error{background:rgba(239,68,68,.12);color:#ef4444} .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-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} .ai-badge-ic.ai-badge-processing .lucide-loader{animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}} @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} .ai-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;cursor:pointer;white-space:nowrap}
+1
View File
@@ -51,6 +51,7 @@ function buildSidebar(active) {
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' }, { page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
{ page: 'journal', label: 'Журнал', icon: 'book-open' }, { page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' }, { page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
{ page: 'feedback', label: 'Обратная связь', icon: 'message-square-heart' },
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true }, { page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
{ page: 'chat', label: 'Чаты', icon: 'messages-square', chatBadge: true }, { page: 'chat', label: 'Чаты', icon: 'messages-square', chatBadge: true },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' },
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Обратная связь — WhatIDo</title>
<link rel="stylesheet" href="admin.css">
<style>
.feedback-list { display:flex; flex-direction:column; gap:10px; }
.feedback-item { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:10px 12px; display:grid; grid-template-columns:120px 140px 160px 100px 140px 1fr 160px; gap:10px; align-items:center; }
.feedback-item .col { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.feedback-item .text { color:var(--text-secondary); font-size:13px; }
.feedback-item .actions { display:flex; gap:6px; flex-wrap:wrap; }
.card-stack { flex-direction: column; align-items: stretch; }
.card-stack .filters { margin-bottom: 0; }
.feedback-item .btn-xs.sent-on { background:var(--accent); color:#fff; }
@media (max-width: 1100px) {
.feedback-item { grid-template-columns:120px 140px 160px 100px 140px 1fr; }
.feedback-item .actions { grid-column:1/-1; justify-content:flex-end; }
}
.shorts-list { display:flex; flex-direction:column; gap:6px; width:100%; max-height:220px; overflow:auto; padding:2px 0; }
.shorts-list .item { display:flex; gap:8px; align-items:flex-start; }
.shorts-list .item label { flex:1; font-size:13px; line-height:1.3; color:var(--text); cursor:pointer; }
</style>
</head>
<body data-page="feedback">
<div class="app">
<aside class="sidebar" id="sidebar"></aside>
<main class="main">
<header class="page-head">
<div>
<h2>Обратная связь</h2>
<div class="page-sub">Отзывы тьютора по итогам занятий</div>
</div>
<button class="btn-primary" id="addFeedbackBtn"><span data-lucide="plus"></span> Добавить отзыв</button>
</header>
<section class="card card-stack">
<div class="filters">
<div><label>Поиск</label><input type="text" id="fbSearch" class="search-input" placeholder="Поиск по ФИО, теме, тексту…"></div>
<div><label>Группа</label><select id="fbGroupFilter"><option value="">Все группы</option></select></div>
<div><label>Статус</label><select id="fbStatusFilter">
<option value="">Все статусы</option>
<option value="sent">Отправлено</option>
<option value="none">Без ИИ</option>
<option value="pending">Ожидает</option>
<option value="processing">В обработке</option>
<option value="done">Готово</option>
<option value="error">Ошибка</option>
<option value="skipped">Пропущен</option>
</select></div>
<div><label>С</label><input type="date" id="fbDateFrom"></div>
<div><label>По</label><input type="date" id="fbDateTo"></div>
<div><label>&nbsp;</label>
<label class="toggle" style="align-items:center">
<input type="checkbox" id="fbDeletedToggle">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text">Показать удалённые</span>
</label>
</div>
</div>
<div class="table-wrap">
<div class="feedback-list" id="feedbackList"></div>
</div>
<div class="card-foot">
<div id="feedbackState" class="muted"></div>
<div id="pager"></div>
</div>
</section>
</main>
</div>
<div class="modal-overlay" id="feedbackModal">
<div class="edit-modal" style="max-width:680px">
<h3 id="fbModalTitle">Добавить отзыв</h3>
<form id="fbForm" class="settings-stack" autocomplete="off">
<input type="hidden" id="fbId">
<div class="settings-stack-grid">
<div class="settings-field">
<label>Дата занятия <span style="color:#ef4444">*</span></label>
<input type="date" id="fbDate" class="settings-input" required>
</div>
<div class="settings-field">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="fbGroup" class="settings-input" required></select>
</div>
</div>
<div class="settings-stack-grid">
<div class="settings-field">
<label>Фамилия/Имя резидента <span style="color:#ef4444">*</span></label>
<input type="text" id="fbResident" class="settings-input" required maxlength="200" placeholder="Иванов Иван" list="fbResidentList" autocomplete="off">
<datalist id="fbResidentList"></datalist>
</div>
<div class="settings-field">
<label>Тема занятия</label>
<input type="text" id="fbTopic" class="settings-input" maxlength="500" placeholder="Тема занятия" list="fbTopicList" autocomplete="off">
<datalist id="fbTopicList"></datalist>
</div>
</div>
<div class="settings-field">
<label>Текст отзыва</label>
<textarea id="fbText" class="settings-input" rows="5" maxlength="5000" placeholder="Текст отзыва (может быть сгенерирован ИИ)"></textarea>
<div class="hint"><span id="fbTextHint">0 / 5000</span></div>
</div>
<div class="settings-stack-grid" style="align-items:start">
<label class="toggle" style="align-items:center">
<input type="checkbox" id="fbAiCheck" checked>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Сгенерировать отзыв ИИ</b><small>Будет отправлено в очередь</small></span>
</label>
<div class="settings-field">
<label>Модель ИИ</label>
<select id="fbModel" class="settings-input"></select>
</div>
</div>
<div class="settings-field">
<label class="toggle" style="align-items:center">
<input type="checkbox" id="fbPastCheck">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text">Добавить в промпт прошлый отзыв</span>
</label>
</div>
<div class="settings-field" id="fbPastReviewWrap" style="display:none">
<label>Прошлый отзыв</label>
<textarea id="fbPastReview" class="settings-input" rows="3" maxlength="2000"></textarea>
</div>
<div class="settings-field" id="fbShortsWrap" style="display:none">
<label>Выберите короткие сообщения для обратной связи (отмечены все)</label>
<div class="shorts-list" id="fbShortsList"></div>
<div class="hint">Эти сообщения будут переданы в промпт как контекст</div>
</div>
<div class="ai-status" id="fbAiStatus" hidden></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="fbCancel">Отмена</button>
<button type="submit" class="btn-primary" id="fbSubmit">Отправить в очередь</button>
</div>
</form>
</div>
</div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/feedback.js"></script>
</body>
</html>
+398
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;'); }
function aiBadge(s) {
switch (s) {
case 'pending': return '<span class="ai-badge pending">Ожидает</span>';
case 'processing': return '<span class="ai-badge processing">В обработке</span>';
case 'done': return '<span class="ai-badge done">Готово</span>';
case 'error': return '<span class="ai-badge error">Ошибка</span>';
case 'skipped': return '<span class="ai-badge skipped">Пропущен</span>';
default: return '<span class="ai-badge none">Без ИИ</span>';
}
}
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 `<div class="feedback-item" data-id="${f.id}"${del}>
<div class="col">${fmtDate(f.feedback_date)}</div>
<div class="col">${escapeHtml(f.topic||'')}</div>
<div class="col">${escapeHtml(f.resident||'')}</div>
<div class="col">${fmtDay(f.feedback_date)}</div>
<div class="col">${escapeHtml(f.group_name||'')}</div>
<div class="col text">${escapeHtml(short)}</div>
<div class="actions">
${aiBadge(f.ai_status)}
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
${f.ai_status === 'error' ? `<button class="btn-xs" data-retry="${f.id}">Повторить</button>` : ''}
${f.deleted_at ? '' : `<button class="btn-xs ${f.sent_at ? 'sent-on' : ''}" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
<button class="btn-xs" data-edit="${f.id}">Изменить</button>
<button class="btn-xs danger" data-del="${f.id}">${f.deleted_at ? 'Удалить навсегда' : 'Удалить'}</button>
${f.deleted_at ? `<button class="btn-xs" data-restore="${f.id}">Восстановить</button>` : ''}
</div>
</div>`;
}
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 => `<option value="${escapeHtml(x.name)}">`).join('');
residentNamesLoaded = true;
} catch (e) {}
}
function applyResidentGroup() {
const k = normName(document.getElementById('fbResident').value);
if (!k || !residentGroups.has(k)) return;
const gid = residentGroups.get(k);
const sel = document.getElementById('fbGroup');
if (!gid || !sel) return;
if (![...sel.options].some(o => o.value === String(gid))) return;
sel.value = String(gid);
}
let topicsLoaded = false;
async function loadTopics() {
if (topicsLoaded) return;
try {
const r = await fetch(`${API}/api/lesson-reports/topics`, { headers: hdr() });
if (!r.ok) return;
const topics = await r.json();
const dl = document.getElementById('fbTopicList');
if (dl) dl.innerHTML = (topics||[]).map(t => `<option value="${escapeHtml(t)}">`).join('');
topicsLoaded = true;
} catch (e) {}
}
async function applyTopicText() {
const topicEl = document.getElementById('fbTopic');
const textEl = document.getElementById('fbText');
const hint = document.getElementById('fbTextHint');
const topic = topicEl.value.trim();
if (!topic) return;
let rep = topicReportCache.get(topic);
if (rep === undefined) {
try {
const r = await fetch(`${API}/api/lesson-reports/by-topic?topic=${encodeURIComponent(topic)}`, { headers: hdr() });
rep = r.ok ? (await r.json()).report : null;
} catch (e) { return; }
topicReportCache.set(topic, rep);
}
const next = (rep && rep.text || '').trim();
if (!next) return;
const cur = textEl.value;
if (cur.trim() && cur !== lastFbAutoText) return;
textEl.value = next;
lastFbAutoText = next;
hint.textContent = `${next.length} / 5000`;
}
async function loadGroups() {
if (groupsLoaded) return;
try {
const r = await fetch(`${API}/api/groups`, { headers: hdr() });
if (r.ok) {
const list = await r.json();
const sel = document.getElementById('fbGroup');
const fsel = document.getElementById('fbGroupFilter');
if (sel) sel.innerHTML = '<option value="">-- выберите --</option>';
if (fsel) fsel.innerHTML = '<option value="">Все группы</option>';
(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 `<div class="item"><input type="checkbox" name="fbShort" value="${s.id}" checked><label>${escapeHtml(s.lesson_date||'')} – ${escapeHtml(topic)} – ${escapeHtml(text)}</label></div>`;
}).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 = '<div class="muted">Загрузка…</div>';
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('') : '<div class="muted">Список пуст</div>';
if (st) st.textContent = `Всего: ${total}`;
renderPager(total, page, PAGE_SIZE, p => { page = p; loadFeedbacks(); });
} else { el.innerHTML = '<div class="muted">Ошибка загрузки</div>'; }
} catch (e) { el.innerHTML = '<div class="muted">Ошибка</div>'; }
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 += `<button class="btn-xs ${i===cur?'primary':''}" data-page="${i}">${i}</button>`;
}
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();
}
})();
+456 -1
View File
@@ -6,7 +6,7 @@ const { ipKeyGenerator } = require('express-rate-limit');
const helmet = require('helmet'); const helmet = require('helmet');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const heicConvert = require('heic-convert'); const heicConvert = require('heic-convert');
const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker } = require('./worker'); const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, createFeedbackWriter } = require('./worker');
const { createZipWriter, renderStudentReport } = require('./student-report'); const { createZipWriter, renderStudentReport } = require('./student-report');
const { createStorage, mimeFor } = require('./storage'); const { createStorage, mimeFor } = require('./storage');
@@ -122,6 +122,10 @@ function invalidateLessonReports() {
cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:');
broadcastLessonChanged(); broadcastLessonChanged();
} }
function invalidateFeedbacks() {
cacheDrop('feedbacks:');
broadcastFeedbackChanged();
}
function invalidateSessions() { cacheDrop('session:'); } function invalidateSessions() { cacheDrop('session:'); }
function invalidateApiKeys() { cacheDrop('apikey:'); cacheDrop('rl:apikey'); } function invalidateApiKeys() { cacheDrop('apikey:'); cacheDrop('rl:apikey'); }
@@ -129,6 +133,7 @@ const EVENTS_CHANNEL = 'whatido:events';
const AI_WAKE_CHANNEL = 'whatido:wake:ai'; const AI_WAKE_CHANNEL = 'whatido:wake:ai';
const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo'; const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo';
const LESSON_AI_WAKE_CHANNEL = 'whatido:wake:lesson-ai'; const LESSON_AI_WAKE_CHANNEL = 'whatido:wake:lesson-ai';
const FEEDBACK_WAKE_CHANNEL = 'whatido:wake:feedback';
function createWorkerBus(channel) { function createWorkerBus(channel) {
return { return {
@@ -155,6 +160,7 @@ function dispatchEvent(payload) {
writeFrame('lesson_report_status', { writeFrame('lesson_report_status', {
id: payload.id, id: payload.id,
ai_status: payload.status, ai_status: payload.status,
status: payload.status,
ai_error: payload.error || null, ai_error: payload.error || null,
text: payload.text === undefined ? null : payload.text, text: payload.text === undefined ? null : payload.text,
text_ai: payload.text_ai === undefined ? null : payload.text_ai, text_ai: payload.text_ai === undefined ? null : payload.text_ai,
@@ -162,6 +168,10 @@ function dispatchEvent(payload) {
}); });
return; 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')) { if (payload && (payload.type === 'entries_changed' || payload.type === 'lessons_changed' || payload.type === 'shorts_changed')) {
writeFrame(payload.type, { ts: Date.now() }); writeFrame(payload.type, { ts: Date.now() });
return; return;
@@ -191,6 +201,11 @@ function broadcastLessonChanged() {
.catch(err => console.error('Publish failed:', err.message)); .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) { function broadcastAiStatus(payload) {
cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload }) cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload })
.catch(err => console.error('Publish failed:', err.message)); .catch(err => console.error('Publish failed:', err.message));
@@ -243,6 +258,7 @@ const NOTIFY_TYPES = {
'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true }, 'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true },
'lesson.report': { label: 'Отчёт о занятии', hint: 'Тьютор сохранил отчёт о том, что прошли на занятии', icon: 'notebook-pen', level: 'info', enabled: true, admin: false }, 'lesson.report': { label: 'Отчёт о занятии', hint: 'Тьютор сохранил отчёт о том, что прошли на занятии', icon: 'notebook-pen', level: 'info', enabled: true, admin: false },
'lesson.ai.formatted': { label: 'Отчёт оформлен по шаблону', hint: 'ИИ привёл текст отчёта о занятии к деловому виду', icon: 'file-check', level: 'info', enabled: false, admin: false }, '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) { 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)`); 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() { function invalidateShorts() {
cache.dropPrefix('shorts:'); cache.dropPrefix('shorts:');
cache.publish(EVENTS_CHANNEL, { type: 'shorts_changed' }).catch(() => {}); 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) => { app.post('/api/shorts/ai-correct', requireAuth, aiLimiter, async (req, res) => {
try { try {
const text = reqStr(req.body?.text, 5000); 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) => { app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
if (req.params.id === String(req.user.id)) { if (req.params.id === String(req.user.id)) {
return res.status(400).json({ error: 'Нельзя удалить самого себя' }); return res.status(400).json({ error: 'Нельзя удалить самого себя' });
@@ -3363,11 +3680,16 @@ const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор т
let entryAutoChecker = null; let entryAutoChecker = null;
let photoWorker = null; let photoWorker = null;
let lessonAiChecker = null; let lessonAiChecker = null;
let feedbackWorker = null;
function wakeLessonAiWorker() { function wakeLessonAiWorker() {
if (lessonAiChecker) lessonAiChecker.notify(); if (lessonAiChecker) lessonAiChecker.notify();
} }
function wakeFeedbackWorker() {
if (feedbackWorker) feedbackWorker.notify();
}
async function onLessonAiDone({ row, status, text, aiText, error }) { async function onLessonAiDone({ row, status, text, aiText, error }) {
invalidateLessonReports(); invalidateLessonReports();
cache.publish(EVENTS_CHANNEL, { 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) { async function aiCorrectText(text, profileOverride) {
try { try {
const systemPrompt = await getAiPrompt(); 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 project_files');
await client.query('DELETE FROM lesson_report_versions'); await client.query('DELETE FROM lesson_report_versions');
await client.query('DELETE FROM lesson_reports'); await client.query('DELETE FROM lesson_reports');
await client.query('DELETE FROM feedbacks');
await client.query('DELETE FROM entries'); await client.query('DELETE FROM entries');
await client.query('DELETE FROM modules'); await client.query('DELETE FROM modules');
await client.query('DELETE FROM students'); 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] [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) { 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; const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount;
await client.query( await client.query(
@@ -3940,7 +4294,9 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
} }
for (const tbl of BACKUP_SEQUENCE_TABLES) { for (const tbl of BACKUP_SEQUENCE_TABLES) {
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl); const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
try {
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]); await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
} catch (e) {}
} }
await client.query('COMMIT'); await client.query('COMMIT');
} catch (e) { } catch (e) {
@@ -4889,6 +5245,41 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
// --- Lesson reports (отчёт о занятии) --- // --- Lesson reports (отчёт о занятии) ---
const LESSON_REPORT_LIST_TTL_MS = 30 * 1000; const LESSON_REPORT_LIST_TTL_MS = 30 * 1000;
const LESSON_AI_VERSION_LIMIT = 50; const LESSON_AI_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 = [ const LESSON_AI_DEFAULT_PROMPT = [
'Ты — редактор сообщений тьютора детской IT-школы.', 'Ты — редактор сообщений тьютора детской IT-школы.',
'', '',
@@ -5038,6 +5429,45 @@ app.get('/api/lesson-reports', requireAuth, async (req, res) => {
res.json({ items: rows, total }); 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) => { app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => {
const { report, error, code } = await lessonReportById(req.user, req.params.id); const { report, error, code } = await lessonReportById(req.user, req.params.id);
if (!report) return res.status(code || 404).json({ error }); if (!report) return res.status(code || 404).json({ error });
@@ -5254,6 +5684,19 @@ app.get('/api/students/names', requireAuth, async (req, res) => {
res.json(rows.map(r => r.name)); 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) => { app.post('/api/students', requireAuth, async (req, res) => {
const { name, group_id } = req.body; const { name, group_id } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); 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 ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); } try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); }
try { await ensureShortMessagesTable(); } catch (err) { console.error('Short messages table:', 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); } try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref(); setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();
try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); } try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); }
@@ -8805,4 +9249,15 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
}); });
lessonAiChecker.start(); lessonAiChecker.start();
console.log('Lesson report AI worker started'); 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');
})(); })();
+299 -1
View File
@@ -1051,4 +1051,302 @@ async function loop() {
return { start, stop, notify, getInfo }; 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 };