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:
@@ -391,6 +391,8 @@ guards against.
|
||||
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
|
||||
| `public/chat.html` | Чат тьютор↔админ: список диалогов, лента сообщений, вложения (Ctrl+V) |
|
||||
| `public/js/chat.js` | Логика чата: загрузка треда/сообщений, отправка, вставка файлов из буфера |
|
||||
| `public/feedback.html` | Страница обратной связи (отзывы тьютора) |
|
||||
| `public/js/feedback.js` | Логика обратной связи: список, модалка, выбор short_messages, ИИ-очередь |
|
||||
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
||||
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
|
||||
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
|
||||
|
||||
@@ -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
@@ -8,13 +8,13 @@ const BACKUP_TABLES = [
|
||||
'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs',
|
||||
'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications',
|
||||
'notification_reads', 'banned_ips', 'chat_threads', 'chat_messages', 'chat_attachments',
|
||||
'short_messages',
|
||||
'short_messages', 'feedbacks',
|
||||
];
|
||||
const BACKUP_SEQUENCE_TABLES = [
|
||||
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos',
|
||||
'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports',
|
||||
'lesson_report_versions', 'audit_log', 'notifications', 'chat_threads', 'chat_messages',
|
||||
'chat_attachments', 'short_messages',
|
||||
'chat_attachments', 'short_messages', 'feedbacks',
|
||||
];
|
||||
|
||||
function isSupportedBackupVersion(data) {
|
||||
@@ -516,7 +516,28 @@ function normalizeRestoreData(data) {
|
||||
created_at: optTs(x.created_at),
|
||||
updated_at: optTs(x.updated_at),
|
||||
}));
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips, chat_threads, chat_messages, chat_attachments, short_messages };
|
||||
const feedbacks = (data.feedbacks || []).map(r => ({
|
||||
id: reqInt(r.id),
|
||||
feedback_date: optDate(r.feedback_date),
|
||||
group_id: reqInt(r.group_id),
|
||||
resident: reqStr(r.resident),
|
||||
topic: optStr(r.topic),
|
||||
past_review: optStr(r.past_review),
|
||||
short_message_ids: Array.isArray(r.short_message_ids) ? r.short_message_ids.map(v => reqInt(v)) : [],
|
||||
text: optStr(r.text),
|
||||
ai_status: optStr(r.ai_status) || 'none',
|
||||
ai_checked_at: optTs(r.ai_checked_at),
|
||||
ai_error: optStr(r.ai_error),
|
||||
ai_profile: optStr(r.ai_profile),
|
||||
sent_at: optTs(r.sent_at),
|
||||
author_id: optInt(r.author_id),
|
||||
branch_id: optInt(r.branch_id),
|
||||
deleted_at: optTs(r.deleted_at),
|
||||
purge_at: optTs(r.purge_at),
|
||||
created_at: optTs(r.created_at),
|
||||
updated_at: optTs(r.updated_at),
|
||||
}));
|
||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips, chat_threads, chat_messages, chat_attachments, short_messages, feedbacks };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
+53
-1
@@ -460,6 +460,32 @@ INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — реда
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('feedback_ai_enabled','true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('feedback_ai_prompt','Ты — помощник по написанию коротких деловых отзывов тьютора детской IT-школы.
|
||||
|
||||
ЗАДАЧА: Сформируй короткий, ёмкий, деловой отзыв по итогам занятия. По стилю он должен быть близок к «короткому сообщению для обратной связи»: 1–3 предложения, без воды, конкретно по делу.
|
||||
|
||||
КОНТЕКСТ, КОТОРЫЙ ТЫ ПОЛУЧИШЬ:
|
||||
- Группа: ...
|
||||
- Дата занятия: ...
|
||||
- Резидент: ...
|
||||
- Тема занятия: ...
|
||||
- Позиция темы: ... (если есть)
|
||||
- Прошлый отзыв: ... (если есть)
|
||||
- Короткие сообщения (выбраны): список в формате "дата – тема – сообщение"
|
||||
|
||||
ПРАВИЛА:
|
||||
- Пиши только отзыв. Без заголовков, списков, markdown, подписей и пояснений.
|
||||
- Не упоминай группу, дату, время занятия в тексте отзыва.
|
||||
- Не начинай со слов «Сегодня», «Вчера», «На занятии», «В ходе занятия», если они избыточны.
|
||||
- Опирайся на факты из контекста: тема занятия, выбранные короткие сообщения и прошлый отзыв (если есть).
|
||||
- Сохраняй деловой, спокойный тон. Без оценок типа «отлично», «замечательно», избегай шаблонных «молодец».
|
||||
- Максимально кратко: 1–2 предложения. Если хватает одного — лучше одно.
|
||||
- Не оборачивай ответ в кавычки.
|
||||
- Не придумывай фактов, которых нет в контексте.
|
||||
|
||||
ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_feedback_ai_ready','false') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS short_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -476,7 +502,33 @@ CREATE TABLE IF NOT EXISTS short_messages (
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('short_messages_retention_days', '365') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Обратная связь (отзывы тьютора)
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feedback_date DATE NOT NULL,
|
||||
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
resident VARCHAR(200) NOT NULL,
|
||||
topic VARCHAR(500),
|
||||
past_review TEXT,
|
||||
short_message_ids INT[] DEFAULT '{}'::int[],
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
ai_profile VARCHAR(100),
|
||||
sent_at TIMESTAMPTZ,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id);
|
||||
|
||||
+101
-20
@@ -456,31 +456,32 @@ INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — реда
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО:
|
||||
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
UPDATE settings
|
||||
SET value = 'Ты — редактор сообщений тьютора детской IT-школы.
|
||||
|
||||
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
|
||||
ЦЕЛЬ
|
||||
Привести текст отчёта о занятии к деловому стилю по строгому шаблону. Начало фразы должно соответствовать позиции темы (первое/промежуточное/последнее занятие модуля), исходя из формулировки «Позиция темы: ...».
|
||||
|
||||
ГЛАВНОЕ:
|
||||
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
|
||||
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
|
||||
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
|
||||
ПРАВИЛА ФОРМУЛИРОВКИ
|
||||
- Всегда начинай фразу строго в соответствии с «Позиция темы»:
|
||||
- если «первое занятие модуля (1 из N)» — начинай с «На занятии ребята начали...» (или близко по смыслу, без шаблонного «На данном занятии»)
|
||||
- если «промежуточное занятие модуля (K из N), K > 1 и K < N» — начинай с «На занятии ребята продолжили...»
|
||||
- если «последнее занятие модуля (N из N)» — начинай с «На занятии ребята завершили...» (при N==1 — это «последнее занятие модуля (1 из 1)», используй «завершили»)
|
||||
- Определи позицию ТОЛЬКО по строке «Позиция темы: ...». Не угадывай по номеру темы сам.
|
||||
- Если «Позиция темы» отсутствует или не содержит N/M — начни нейтрально, без жёсткого шаблона, но сохрани деловой стиль.
|
||||
- Перепиши текст в деловом стиле, сохрани смысл, факты и ключевые термины.
|
||||
- Убери разговорные слова, лишние повторы, воду.
|
||||
- Не добавляй факты, которых нет в исходном тексте.
|
||||
- Не используй оценочные слова («отлично», «хорошо», «замечательно»), не пиши «молодцы».
|
||||
|
||||
СТИЛЬ И СТРУКТУРА:
|
||||
- Один цельный абзац, 2–4 предложения, от третьего лица.
|
||||
- Первое предложение всегда начинай со слов «На занятии ребята …». Чем занимались в начале занятия — начало модуля, продолжение или завершение — определяй по правилу НАЧАЛО ФРАЗЫ.
|
||||
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
|
||||
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
|
||||
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
|
||||
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
|
||||
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
|
||||
ЗАПРЕТЫ
|
||||
- Не упоминай дату, время, группу, номер занятия и модуль.
|
||||
- Не начинай со слов «Сегодня», «Вчера».
|
||||
- Не используй шаблон «На данном занятии», «В рамках занятия».
|
||||
- Не пиши список (маркированный/нумерованный). Только сплошной текст.
|
||||
|
||||
НАЧАЛО ФРАЗЫ (определяется номером темы):
|
||||
Система присылает готовую подсказку строкой «Позиция темы: …». Если такой строки нет, смотри номер вида N/M в конце строки «Тема занятия: …», где N — номер занятия в модуле, M — сколько занятий в модуле всего. Пользуйся только одной из этих подсказок и всегда проверяй, что N не больше M.
|
||||
- «последнее занятие модуля» (N = M, например 2/2 или 7/7): занятие завершает модуль. Первое предложение обязательно начни с «завершили» или «закончили»: «На занятии ребята завершили изучение …», «На занятии ребята закончили знакомство с …».
|
||||
- «промежуточное занятие модуля» (1 < N < M, например 2/4, 3/5, 6/7): изучение продолжается. Первое предложение обязательно начни с «продолжили», «продолжали», «закрепили» или «углубили»: «На занятии ребята продолжили изучение …», «На занятии ребята закрепили …».
|
||||
- «первое занятие модуля» (N = 1 и N < M, например 1/4 или 1/7): модуль начинается. Первое предложение обязательно начни с «начали» или «приступили»: «На занятии ребята начали знакомство с …», «На занятии ребята начали изучение …».
|
||||
- Если подсказки нет или номер нечитаем — характер занятия не выбирай и не додумывай. Начни нейтрально, прямо с сути: «На занятии ребята разобрались …», «На занятии ребята отработали …», «На занятии ребята попробовали …».
|
||||
ГЛАВНОЕ ПРАВИЛО
|
||||
- Это требование сильнее всего остального: даже если начало исходного текста уже хорошо звучит, перепиши его по правилу выше. Слово «модуль», цифры номера и любые оценки в текст отчёта не переноси: номер темы — внутренний признак для выбора формулировки.
|
||||
|
||||
ФОРМАТ:
|
||||
@@ -501,7 +502,6 @@ SET value = 'Ты — редактор сообщений тьютора дет
|
||||
«На занятии ребята продолжили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
|
||||
Тот же исходный текст, но «Позиция темы: последнее занятие модуля (3 из 3)»:
|
||||
«На занятии ребята завершили знакомство со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей…».
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО:
|
||||
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.'
|
||||
WHERE key = 'lesson_ai_prompt' AND value IN (
|
||||
@@ -589,3 +589,84 @@ ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS used_at DATE;
|
||||
ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('short_messages_retention_days', '365') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Feedbacks
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'feedbacks') THEN
|
||||
CREATE TABLE feedbacks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feedback_date DATE NOT NULL,
|
||||
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
resident VARCHAR(200) NOT NULL,
|
||||
topic VARCHAR(500),
|
||||
past_review TEXT,
|
||||
short_message_ids INT[] DEFAULT '{}'::int[],
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
ai_profile VARCHAR(100),
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id);
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS feedback_date DATE;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS group_id INT;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS resident VARCHAR(200);
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS topic VARCHAR(500);
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS past_review TEXT;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS short_message_ids INT[] DEFAULT '{}'::int[];
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS text TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100);
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT now();
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
|
||||
|
||||
-- Индексы (если таблица существовала без них)
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('feedback_ai_enabled','true') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('feedback_ai_prompt','Ты — помощник по написанию коротких деловых отзывов тьютора детской IT-школы.
|
||||
|
||||
ЗАДАЧА: Сформируй короткий, ёмкий, деловой отзыв по итогам занятия. По стилю он должен быть близок к «короткому сообщению для обратной связи»: 1–3 предложения, без воды, конкретно по делу.
|
||||
|
||||
КОНТЕКСТ, КОТОРЫЙ ТЫ ПОЛУЧИШЬ:
|
||||
- Группа: ...
|
||||
- Дата занятия: ...
|
||||
- Резидент: ...
|
||||
- Тема занятия: ...
|
||||
- Позиция темы: ... (если есть)
|
||||
- Прошлый отзыв: ... (если есть)
|
||||
- Короткие сообщения (выбраны): список в формате "дата – тема – сообщение"
|
||||
|
||||
ПРАВИЛА:
|
||||
- Пиши только отзыв. Без заголовков, списков, markdown, подписей и пояснений.
|
||||
- Не упоминай группу, дату, время занятия в тексте отзыва.
|
||||
- Не начинай со слов «Сегодня», «Вчера», «На занятии», «В ходе занятия», если они избыточны.
|
||||
- Опирайся на факты из контекста: тема занятия, выбранные короткие сообщения и прошлый отзыв (если есть).
|
||||
- Сохраняй деловой, спокойный тон. Без оценок типа «отлично», «замечательно», избегай шаблонных «молодец».
|
||||
- Максимально кратко: 1–2 предложения. Если хватает одного — лучше одно.
|
||||
- Не оборачивай ответ в кавычки.
|
||||
- Не придумывай фактов, которых нет в контексте.
|
||||
|
||||
ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_feedback_ai_ready','false') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
@@ -216,9 +216,14 @@ services:
|
||||
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
|
||||
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
|
||||
HF_HOME: /root/.cache/huggingface
|
||||
TORCH_HOME: /root/.cache/torch
|
||||
TRANSFORMERS_CACHE: /root/.cache/huggingface
|
||||
XDG_CACHE_HOME: /root/.cache
|
||||
TZ: Europe/Moscow
|
||||
volumes:
|
||||
- photo-ai-models:/models
|
||||
- photo-ai-cache:/root/.cache
|
||||
ports:
|
||||
- "127.0.0.1:8081:8080"
|
||||
healthcheck:
|
||||
@@ -232,5 +237,6 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
photo-ai-models:
|
||||
photo-ai-cache:
|
||||
redis-data:
|
||||
s3-data:
|
||||
|
||||
+16
-4
@@ -6,14 +6,26 @@ ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
HF_HOME=/root/.cache/huggingface \
|
||||
TORCH_HOME=/root/.cache/torch \
|
||||
TRANSFORMERS_CACHE=/root/.cache/huggingface \
|
||||
XDG_CACHE_HOME=/root/.cache
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy==2.2.6"
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --upgrade pip && \
|
||||
pip install "typing-extensions==4.12.2" "numpy==2.2.6"
|
||||
|
||||
RUN pip install --no-cache-dir torch torchvision --index-url ${TORCH_INDEX}
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install torch torchvision --index-url ${TORCH_INDEX}
|
||||
|
||||
RUN pip install --no-cache-dir --no-deps basicsr==1.4.2 realesrgan==0.3.0 gfpgan==1.3.8 facexlib==0.3.0 && \
|
||||
pip install --no-cache-dir "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-deps basicsr==1.4.2 realesrgan==0.3.0 gfpgan==1.3.8 facexlib==0.3.0 && \
|
||||
pip install "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \
|
||||
requests scikit-image scipy tqdm filterpy numba fastapi "uvicorn[standard]" python-multipart
|
||||
|
||||
RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
|
||||
|
||||
@@ -81,6 +81,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end}
|
||||
.filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem}
|
||||
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
|
||||
.filters label.toggle{display:flex;align-items:center;gap:8px;margin-bottom:0;padding:6px 10px;color:var(--text);background:transparent;border-color:transparent;min-width:0}
|
||||
.filters label.toggle .toggle-text{font-size:.8rem;white-space:nowrap}
|
||||
.filters > div{min-width:0}
|
||||
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
#studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
|
||||
@@ -489,6 +492,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.ai-badge-done{background:rgba(22,163,74,.12);color:#16a34a}
|
||||
.ai-badge-error{background:rgba(239,68,68,.12);color:#ef4444}
|
||||
.ai-badge-skipped,.ai-badge-reverted{background:rgba(148,163,184,.18);color:var(--muted)}
|
||||
.ai-badge-sent{background:rgba(20,184,166,.12);color:#14b8a6}
|
||||
.ai-badge-ic.ai-badge-processing .lucide-loader{animation:spin 1s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.ai-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;cursor:pointer;white-space:nowrap}
|
||||
|
||||
@@ -51,6 +51,7 @@ function buildSidebar(active) {
|
||||
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
|
||||
{ page: 'feedback', label: 'Обратная связь', icon: 'message-square-heart' },
|
||||
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
|
||||
{ page: 'chat', label: 'Чаты', icon: 'messages-square', chatBadge: true },
|
||||
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||
|
||||
@@ -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> </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>
|
||||
@@ -0,0 +1,398 @@
|
||||
(async function() {
|
||||
const API = location.origin;
|
||||
const PAGE_SIZE = 20;
|
||||
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '';
|
||||
const topicReportCache = new Map();
|
||||
|
||||
function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; }
|
||||
function fmtDate(d) { if (!d) return ''; if (window.datetime && datetime.fmtShortIso) return datetime.fmtShortIso(d); return d; }
|
||||
function fmtDay(d) {
|
||||
if (!d) return '';
|
||||
try { const dt = new Date(d + 'T12:00:00'); return dt.toLocaleDateString('ru-RU', { weekday: 'short' }); } catch (e) { return ''; }
|
||||
}
|
||||
function escapeHtml(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -6,7 +6,7 @@ const { ipKeyGenerator } = require('express-rate-limit');
|
||||
const helmet = require('helmet');
|
||||
const bcrypt = require('bcrypt');
|
||||
const heicConvert = require('heic-convert');
|
||||
const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker } = require('./worker');
|
||||
const { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, createFeedbackWriter } = require('./worker');
|
||||
const { createZipWriter, renderStudentReport } = require('./student-report');
|
||||
|
||||
const { createStorage, mimeFor } = require('./storage');
|
||||
@@ -122,6 +122,10 @@ function invalidateLessonReports() {
|
||||
cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:');
|
||||
broadcastLessonChanged();
|
||||
}
|
||||
function invalidateFeedbacks() {
|
||||
cacheDrop('feedbacks:');
|
||||
broadcastFeedbackChanged();
|
||||
}
|
||||
function invalidateSessions() { cacheDrop('session:'); }
|
||||
function invalidateApiKeys() { cacheDrop('apikey:'); cacheDrop('rl:apikey'); }
|
||||
|
||||
@@ -129,6 +133,7 @@ const EVENTS_CHANNEL = 'whatido:events';
|
||||
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
|
||||
const PHOTO_WAKE_CHANNEL = 'whatido:wake:photo';
|
||||
const LESSON_AI_WAKE_CHANNEL = 'whatido:wake:lesson-ai';
|
||||
const FEEDBACK_WAKE_CHANNEL = 'whatido:wake:feedback';
|
||||
|
||||
function createWorkerBus(channel) {
|
||||
return {
|
||||
@@ -155,6 +160,7 @@ function dispatchEvent(payload) {
|
||||
writeFrame('lesson_report_status', {
|
||||
id: payload.id,
|
||||
ai_status: payload.status,
|
||||
status: payload.status,
|
||||
ai_error: payload.error || null,
|
||||
text: payload.text === undefined ? null : payload.text,
|
||||
text_ai: payload.text_ai === undefined ? null : payload.text_ai,
|
||||
@@ -162,6 +168,10 @@ function dispatchEvent(payload) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (payload && payload.type === 'feedback_status') {
|
||||
writeFrame('feedback_status', payload);
|
||||
return;
|
||||
}
|
||||
if (payload && (payload.type === 'entries_changed' || payload.type === 'lessons_changed' || payload.type === 'shorts_changed')) {
|
||||
writeFrame(payload.type, { ts: Date.now() });
|
||||
return;
|
||||
@@ -191,6 +201,11 @@ function broadcastLessonChanged() {
|
||||
.catch(err => console.error('Publish failed:', err.message));
|
||||
}
|
||||
|
||||
function broadcastFeedbackChanged() {
|
||||
cache.publish(EVENTS_CHANNEL, { type: 'feedbacks_changed', ts: Date.now() })
|
||||
.catch(err => console.error('Publish failed:', err.message));
|
||||
}
|
||||
|
||||
function broadcastAiStatus(payload) {
|
||||
cache.publish(EVENTS_CHANNEL, { type: 'ai_status', ...payload })
|
||||
.catch(err => console.error('Publish failed:', err.message));
|
||||
@@ -243,6 +258,7 @@ const NOTIFY_TYPES = {
|
||||
'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true },
|
||||
'lesson.report': { label: 'Отчёт о занятии', hint: 'Тьютор сохранил отчёт о том, что прошли на занятии', icon: 'notebook-pen', level: 'info', enabled: true, admin: false },
|
||||
'lesson.ai.formatted': { label: 'Отчёт оформлен по шаблону', hint: 'ИИ привёл текст отчёта о занятии к деловому виду', icon: 'file-check', level: 'info', enabled: false, admin: false },
|
||||
'feedback.ai.ready': { label: 'Отзыв сгенерирован', hint: 'ИИ сформировал отзыв по результатам занятия', icon: 'message-square-heart', level: 'info', enabled: true, admin: false },
|
||||
};
|
||||
|
||||
function notifySettingKey(type) {
|
||||
@@ -2644,6 +2660,53 @@ async function ensureShortMessagesTable() {
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at)`);
|
||||
}
|
||||
|
||||
async function ensureFeedbacksTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feedback_date DATE NOT NULL,
|
||||
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
resident VARCHAR(200) NOT NULL,
|
||||
topic VARCHAR(500),
|
||||
past_review TEXT,
|
||||
short_message_ids INT[] DEFAULT '{}'::int[],
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
ai_profile VARCHAR(100),
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS feedback_date DATE`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS group_id INT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS resident VARCHAR(200)`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS topic VARCHAR(500)`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS past_review TEXT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS short_message_ids INT[] DEFAULT '{}'::int[]`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS text TEXT NOT NULL DEFAULT ''`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100)`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT now()`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_date ON feedbacks(feedback_date DESC)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_deleted ON feedbacks(deleted_at)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feedbacks_pending ON feedbacks(ai_status, id)`);
|
||||
await pool.query(`INSERT INTO settings (key,value) VALUES ('feedback_ai_enabled','true') ON CONFLICT (key) DO NOTHING`);
|
||||
await pool.query(`INSERT INTO settings (key,value) VALUES ('feedback_ai_prompt','${FEEDBACK_AI_DEFAULT_PROMPT.replace(/'/g,"''")}') ON CONFLICT (key) DO NOTHING`);
|
||||
await pool.query(`INSERT INTO settings (key,value) VALUES ('notify_feedback_ai_ready','false') ON CONFLICT (key) DO NOTHING`);
|
||||
}
|
||||
|
||||
function invalidateShorts() {
|
||||
cache.dropPrefix('shorts:');
|
||||
cache.publish(EVENTS_CHANNEL, { type: 'shorts_changed' }).catch(() => {});
|
||||
@@ -2842,6 +2905,144 @@ app.delete('/api/shorts/:id/permanent', requireAuth, requireAdmin, async (req, r
|
||||
}
|
||||
});
|
||||
|
||||
// Feedbacks
|
||||
async function feedbackById(user, id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT f.*, g.name AS group_name, g.branch_id AS group_branch_id
|
||||
FROM feedbacks f
|
||||
LEFT JOIN groups g ON g.id = f.group_id
|
||||
WHERE f.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
const row = rows[0];
|
||||
if (user.role !== 'admin') {
|
||||
const bid = row.branch_id ?? row.group_branch_id ?? null;
|
||||
if (bid != null) {
|
||||
const ub = user.branch_ids || [];
|
||||
if (!ub.includes(bid)) return { forbidden: true };
|
||||
} else {
|
||||
return { forbidden: true };
|
||||
}
|
||||
}
|
||||
row.branch_id = row.branch_id ?? row.group_branch_id ?? null;
|
||||
return row;
|
||||
}
|
||||
|
||||
function parseFeedbackDate(s) {
|
||||
const str = typeof s === 'string' ? s.trim() : '';
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
||||
return str;
|
||||
}
|
||||
|
||||
app.get('/api/feedbacks', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
const dateFrom = parseFeedbackDate(req.query.date_from);
|
||||
const dateTo = parseFeedbackDate(req.query.date_to);
|
||||
const groupId = parseInt(req.query.group_id, 10);
|
||||
const status = typeof req.query.status === 'string' ? req.query.status.trim() : '';
|
||||
const deleted = req.query.deleted === '1';
|
||||
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
|
||||
const resident = typeof req.query.resident === 'string' ? req.query.resident.trim() : '';
|
||||
const key = ['feedbacks:list:', scopeKey(req.user), dateFrom || '', dateTo || '', groupId || '', status || '', deleted ? '1' : '', search || '', resident || '', limit || '', offset || ''].join(':');
|
||||
const payload = await cacheWrap(key, FEEDBACK_LIST_TTL_MS, async () => {
|
||||
const params = [];
|
||||
const cond = [];
|
||||
if (deleted) cond.push('f.deleted_at IS NOT NULL');
|
||||
else {
|
||||
cond.push('f.deleted_at IS NULL');
|
||||
if (status === 'sent') cond.push('f.sent_at IS NOT NULL');
|
||||
else cond.push('f.sent_at IS NULL');
|
||||
}
|
||||
if (dateFrom) { params.push(dateFrom); cond.push(`f.feedback_date >= $${params.length}`); }
|
||||
if (dateTo) { params.push(dateTo); cond.push(`f.feedback_date <= $${params.length}`); }
|
||||
if (Number.isInteger(groupId) && groupId > 0) { params.push(groupId); cond.push(`f.group_id = $${params.length}`); }
|
||||
if (resident) { params.push(`%${resident}%`); cond.push(`f.resident ILIKE $${params.length}`); }
|
||||
if (status && status !== 'sent') { params.push(status); cond.push(`f.ai_status = $${params.length}`); }
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
cond.push(`(f.resident ILIKE $${params.length} OR f.topic ILIKE $${params.length} OR f.text ILIKE $${params.length})`);
|
||||
}
|
||||
const bs = branchScope(req.user);
|
||||
if (bs.ids) { params.push(bs.ids); cond.push(`f.branch_id = ANY($${params.length}::int[])`); }
|
||||
const where = cond.length ? `WHERE ${cond.join(' AND ')}` : '';
|
||||
const countParams = params.slice();
|
||||
params.push(limit, offset);
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM feedbacks f ${where}`, countParams);
|
||||
const items = await pool.query(
|
||||
`SELECT f.id, f.feedback_date, f.group_id, f.resident, f.topic, f.past_review, f.short_message_ids, f.text, f.ai_status, f.ai_checked_at, f.ai_error, f.ai_profile, f.sent_at, f.author_id, f.branch_id, f.deleted_at, f.purge_at, f.created_at, f.updated_at, g.name AS group_name
|
||||
FROM feedbacks f
|
||||
LEFT JOIN groups g ON g.id = f.group_id
|
||||
${where}
|
||||
ORDER BY f.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
);
|
||||
return { items: items.rows, total: parseInt(count.rows[0].count, 10) };
|
||||
});
|
||||
res.json(payload);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/feedbacks/:id', requireAuth, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
res.json(fb);
|
||||
});
|
||||
|
||||
app.post('/api/feedbacks', requireAuth, async (req, res) => {
|
||||
const feedbackDate = parseFeedbackDate(req.body?.feedback_date);
|
||||
if (!feedbackDate) return res.status(400).json({ error: 'Некорректная дата' });
|
||||
const resident = reqStr(req.body?.resident, FEEDBACK_RESIDENT_MAX);
|
||||
const topic = optStr(req.body?.topic, FEEDBACK_TOPIC_MAX);
|
||||
const text = optStr(req.body?.text, FEEDBACK_TEXT_MAX);
|
||||
const groupId = parseInt(req.body?.group_id, 10);
|
||||
if (!Number.isInteger(groupId) || groupId < 1) return res.status(400).json({ error: 'Некорректная группа' });
|
||||
const pastReview = optStr(req.body?.past_review, 2000);
|
||||
let shortIds = Array.isArray(req.body?.short_message_ids) ? req.body.short_message_ids.map(Number).filter(v => Number.isInteger(v) && v > 0) : [];
|
||||
shortIds = [...new Set(shortIds)];
|
||||
const aiCheck = !!req.body?.ai_check;
|
||||
const aiProfile = optStr(req.body?.ai_profile, 100);
|
||||
if (req.user.role !== 'admin') {
|
||||
const ok = await groupBelongsToBranches(req.user, groupId);
|
||||
if (!ok) return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||
}
|
||||
const g = await pool.query('SELECT branch_id FROM groups WHERE id = $1', [groupId]);
|
||||
if (!g.rows.length) return res.status(400).json({ error: 'Группа не найдена' });
|
||||
const branchId = g.rows[0].branch_id;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const insert = await client.query(
|
||||
`INSERT INTO feedbacks (feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_profile, author_id, branch_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
|
||||
[feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', aiCheck ? 'pending' : 'none', aiProfile || null, req.user.id, branchId]
|
||||
);
|
||||
const fb = insert.rows[0];
|
||||
if (shortIds.length) {
|
||||
await client.query(`UPDATE short_messages SET used = true, used_at = CURRENT_DATE WHERE id = ANY($1::int[]) AND deleted_at IS NULL`, [shortIds]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
await logAudit(req, 'feedback.create', { id: fb.id, group_id: groupId, resident, ai_check: aiCheck, ai_status: fb.ai_status, short_count: shortIds.length });
|
||||
invalidateFeedbacks();
|
||||
invalidateShorts();
|
||||
if (aiCheck) wakeFeedbackWorker();
|
||||
res.status(201).json(fb);
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shorts/ai-correct', requireAuth, aiLimiter, async (req, res) => {
|
||||
try {
|
||||
const text = reqStr(req.body?.text, 5000);
|
||||
@@ -2992,6 +3193,122 @@ app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/feedbacks/:id', requireAuth, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const feedbackDate = parseFeedbackDate(req.body?.feedback_date);
|
||||
if (!feedbackDate) return res.status(400).json({ error: 'Некорректная дата' });
|
||||
const resident = reqStr(req.body?.resident, FEEDBACK_RESIDENT_MAX);
|
||||
const topic = optStr(req.body?.topic, FEEDBACK_TOPIC_MAX);
|
||||
const text = optStr(req.body?.text, FEEDBACK_TEXT_MAX);
|
||||
const groupId = parseInt(req.body?.group_id, 10);
|
||||
if (!Number.isInteger(groupId) || groupId < 1) return res.status(400).json({ error: 'Некорректная группа' });
|
||||
const pastReview = optStr(req.body?.past_review, 2000);
|
||||
let shortIds = Array.isArray(req.body?.short_message_ids) ? req.body.short_message_ids.map(Number).filter(v => Number.isInteger(v) && v > 0) : [];
|
||||
shortIds = [...new Set(shortIds)];
|
||||
if (req.user.role !== 'admin') {
|
||||
const ok = await groupBelongsToBranches(req.user, groupId);
|
||||
if (!ok) return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE feedbacks SET feedback_date=$1, group_id=$2, resident=$3, topic=$4, past_review=$5, short_message_ids=$6, text=$7, updated_at=now() WHERE id=$8`,
|
||||
[feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', id]
|
||||
);
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/feedbacks/:id', requireAuth, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const d = await pool.query('SELECT COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) AS d');
|
||||
const days = d.rows[0]?.d || 30;
|
||||
await pool.query('UPDATE feedbacks SET deleted_at=now(), purge_at = now() + ($1 || \' days\')::interval WHERE id=$2 AND deleted_at IS NULL', [days, id]);
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/feedbacks/:id/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
await pool.query('UPDATE feedbacks SET deleted_at=NULL, purge_at=NULL WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/feedbacks/:id/permanent', requireAuth, requireAdmin, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
await pool.query('UPDATE feedbacks SET purge_at = now() + (COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) || \' days\')::interval WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/feedbacks/:id/sent', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const sent = typeof req.body?.sent === 'boolean' ? req.body.sent : null;
|
||||
if (sent === null) return res.status(400).json({ error: 'Некорректный статус' });
|
||||
const shortIds = Array.isArray(fb.short_message_ids) ? fb.short_message_ids : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('UPDATE feedbacks SET sent_at = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL', [sent ? new Date() : null, id]);
|
||||
if (shortIds.length) {
|
||||
if (sent) {
|
||||
await client.query('UPDATE short_messages SET used = true, used_at = CURRENT_DATE WHERE id = ANY($1::int[]) AND deleted_at IS NULL', [shortIds]);
|
||||
} else {
|
||||
await client.query(`UPDATE short_messages sm SET used = false, used_at = NULL
|
||||
WHERE sm.id = ANY($1::int[]) AND sm.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM feedbacks f WHERE f.deleted_at IS NULL AND f.id <> $2 AND sm.id = ANY(f.short_message_ids))`, [shortIds, id]);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
await logAudit(req, 'feedback.sent', { id, sent, short_count: shortIds.length });
|
||||
invalidateFeedbacks();
|
||||
invalidateShorts();
|
||||
res.json({ ok: true, sent });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/feedbacks/:id/ai/retry', requireAuth, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL, updated_at=now() WHERE id=$1 AND deleted_at IS NULL`, [id]);
|
||||
wakeFeedbackWorker();
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/feedbacks/requeue-failed', requireAuth, requireAdmin, async (req, res) => {
|
||||
await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL, updated_at=now() WHERE ai_status='error' AND deleted_at IS NULL`);
|
||||
wakeFeedbackWorker();
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
||||
if (req.params.id === String(req.user.id)) {
|
||||
return res.status(400).json({ error: 'Нельзя удалить самого себя' });
|
||||
@@ -3363,11 +3680,16 @@ const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор т
|
||||
let entryAutoChecker = null;
|
||||
let photoWorker = null;
|
||||
let lessonAiChecker = null;
|
||||
let feedbackWorker = null;
|
||||
|
||||
function wakeLessonAiWorker() {
|
||||
if (lessonAiChecker) lessonAiChecker.notify();
|
||||
}
|
||||
|
||||
function wakeFeedbackWorker() {
|
||||
if (feedbackWorker) feedbackWorker.notify();
|
||||
}
|
||||
|
||||
async function onLessonAiDone({ row, status, text, aiText, error }) {
|
||||
invalidateLessonReports();
|
||||
cache.publish(EVENTS_CHANNEL, {
|
||||
@@ -3473,6 +3795,28 @@ async function aiComplete(systemPrompt, userText, maxTokensOverride, profileOver
|
||||
}
|
||||
}
|
||||
|
||||
async function onFeedbackDone({ row, status, text, aiText, error }) {
|
||||
invalidateFeedbacks();
|
||||
cache.publish(EVENTS_CHANNEL, {
|
||||
type: 'feedback_status',
|
||||
id: row.id,
|
||||
ai_status: status,
|
||||
status,
|
||||
error: error || null,
|
||||
text,
|
||||
ts: Date.now()
|
||||
}).catch(() => {});
|
||||
if (status === 'done') {
|
||||
try {
|
||||
const lenBefore = row.text ? row.text.length : 0;
|
||||
const lenAfter = (aiText || '').length;
|
||||
logAudit(null, 'feedback.ai.generate', { id: row.id, len_before: lenBefore, len_after: lenAfter }).catch(() => {});
|
||||
const branchId = row.branch_id ?? null;
|
||||
pushNotification('feedback.ai.ready', { id: row.id, resident: row.resident }, branchId).catch(() => {});
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function aiCorrectText(text, profileOverride) {
|
||||
try {
|
||||
const systemPrompt = await getAiPrompt();
|
||||
@@ -3759,6 +4103,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
await client.query('DELETE FROM project_files');
|
||||
await client.query('DELETE FROM lesson_report_versions');
|
||||
await client.query('DELETE FROM lesson_reports');
|
||||
await client.query('DELETE FROM feedbacks');
|
||||
await client.query('DELETE FROM entries');
|
||||
await client.query('DELETE FROM modules');
|
||||
await client.query('DELETE FROM students');
|
||||
@@ -3919,6 +4264,15 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
[x.id, x.resident, x.lesson_date, x.topic, x.message, x.used, x.used_at, okUser ? x.author_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at]
|
||||
);
|
||||
}
|
||||
for (const x of (ndata.feedbacks || [])) {
|
||||
const okGroup = x.group_id == null || (await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id])).rowCount;
|
||||
const okUser = x.author_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.author_id])).rowCount;
|
||||
const okBranch = x.branch_id == null || (await client.query('SELECT 1 FROM branches WHERE id = $1', [x.branch_id])).rowCount;
|
||||
await client.query(
|
||||
'INSERT INTO feedbacks (id, feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_checked_at, ai_error, ai_profile, author_id, branch_id, deleted_at, purge_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)',
|
||||
[x.id, x.feedback_date, okGroup ? x.group_id : null, x.resident, x.topic, x.past_review, Array.isArray(x.short_message_ids) ? x.short_message_ids : [], x.text || '', x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.ai_profile, okUser ? x.author_id : null, okBranch ? x.branch_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.audit_log) {
|
||||
const okUser = x.user_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount;
|
||||
await client.query(
|
||||
@@ -3940,7 +4294,9 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
}
|
||||
for (const tbl of BACKUP_SEQUENCE_TABLES) {
|
||||
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
|
||||
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
||||
try {
|
||||
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
||||
} catch (e) {}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
@@ -4889,6 +5245,41 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
|
||||
// --- Lesson reports (отчёт о занятии) ---
|
||||
const LESSON_REPORT_LIST_TTL_MS = 30 * 1000;
|
||||
const LESSON_AI_VERSION_LIMIT = 50;
|
||||
const LESSON_AI_TIMEOUT_MS = 120000;
|
||||
|
||||
const FEEDBACK_AI_VERSION_LIMIT = 50;
|
||||
const FEEDBACK_AI_TIMEOUT_MS = 120000;
|
||||
const FEEDBACK_LIST_TTL_MS = 30 * 1000;
|
||||
const FEEDBACK_TEXT_MAX = 5000;
|
||||
const FEEDBACK_TOPIC_MAX = 500;
|
||||
const FEEDBACK_RESIDENT_MAX = 200;
|
||||
const FEEDBACK_AI_DEFAULT_PROMPT = [
|
||||
'Ты — помощник по написанию коротких деловых отзывов тьютора детской IT-школы.',
|
||||
'',
|
||||
'ЗАДАЧА: Сформируй короткий, ёмкий, деловой отзыв по итогам занятия. По стилю он должен быть близок к «короткому сообщению для обратной связи»: 1–3 предложения, без воды, конкретно по делу.',
|
||||
'',
|
||||
'КОНТЕКСТ, КОТОРЫЙ ТЫ ПОЛУЧИШЬ:',
|
||||
'- Группа: ...',
|
||||
'- Дата занятия: ...',
|
||||
'- Резидент: ...',
|
||||
'- Тема занятия: ...',
|
||||
'- Позиция темы: ... (если есть)',
|
||||
'- Прошлый отзыв: ... (если есть)',
|
||||
'- Короткие сообщения (выбраны): список в формате "дата – тема – сообщение"',
|
||||
'',
|
||||
'ПРАВИЛА:',
|
||||
'- Пиши только отзыв. Без заголовков, списков, markdown, подписей и пояснений.',
|
||||
'- Не упоминай группу, дату, время занятия в тексте отзыва.',
|
||||
'- Не начинай со слов «Сегодня», «Вчера», «На занятии», «В ходе занятия», если они избыточны.',
|
||||
'- Опирайся на факты из контекста: тема занятия, выбранные короткие сообщения и прошлый отзыв (если есть).',
|
||||
'- Сохраняй деловой, спокойный тон. Без оценок типа «отлично», «замечательно», избегай шаблонных «молодец».',
|
||||
'- Максимально кратко: 1–2 предложения. Если хватает одного — лучше одно.',
|
||||
'- Не оборачивай ответ в кавычки.',
|
||||
'- Не придумывай факты, которых нет в контексте.',
|
||||
'',
|
||||
'ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.'
|
||||
].join('\n');
|
||||
|
||||
const LESSON_AI_DEFAULT_PROMPT = [
|
||||
'Ты — редактор сообщений тьютора детской IT-школы.',
|
||||
'',
|
||||
@@ -5038,6 +5429,45 @@ app.get('/api/lesson-reports', requireAuth, async (req, res) => {
|
||||
res.json({ items: rows, total });
|
||||
});
|
||||
|
||||
app.get('/api/lesson-reports/topics', requireAuth, async (req, res) => {
|
||||
const bw = branchWhere(req.user, 'g');
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT lr.topic FROM lesson_reports lr
|
||||
JOIN groups g ON g.id = lr.group_id
|
||||
WHERE lr.topic IS NOT NULL AND lr.topic <> ''${bw.where}
|
||||
ORDER BY lr.topic`,
|
||||
bw.params
|
||||
);
|
||||
res.json(rows.map(r => r.topic));
|
||||
});
|
||||
|
||||
app.get('/api/lesson-reports/by-topic', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const topic = typeof req.query.topic === 'string' ? req.query.topic.trim() : '';
|
||||
if (!topic) return res.status(400).json({ error: 'Укажите тему' });
|
||||
const cond = ['lr.topic = $1'];
|
||||
const params = [topic];
|
||||
const s = branchScope(req.user);
|
||||
if (!s.admin) {
|
||||
if (!s.ids.length) cond.push('1 = 0');
|
||||
else { params.push(s.ids); cond.push(`g.branch_id = ANY($${params.length}::int[])`); }
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT lr.id, lr.group_id, lr.lesson_date, lr.topic, lr.text, g.name AS group_name
|
||||
FROM lesson_reports lr
|
||||
JOIN groups g ON g.id = lr.group_id
|
||||
WHERE ${cond.join(' AND ')}
|
||||
ORDER BY lr.lesson_date DESC, lr.id DESC
|
||||
LIMIT 1`,
|
||||
params
|
||||
);
|
||||
res.json({ report: rows.length ? rows[0] : null });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
@@ -5254,6 +5684,19 @@ app.get('/api/students/names', requireAuth, async (req, res) => {
|
||||
res.json(rows.map(r => r.name));
|
||||
});
|
||||
|
||||
app.get('/api/students/names-with-groups', requireAuth, async (req, res) => {
|
||||
const bw = branchWhere(req.user, 'g');
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT ON (e.student_name) e.student_name AS name, e.group_id, g.name AS group_name
|
||||
FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id
|
||||
WHERE e.student_name IS NOT NULL AND e.student_name <> ''${bw.where}
|
||||
ORDER BY e.student_name, e.created_at DESC NULLS LAST, e.id DESC`,
|
||||
bw.params
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.post('/api/students', requireAuth, async (req, res) => {
|
||||
const { name, group_id } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
@@ -8755,6 +9198,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
|
||||
try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); }
|
||||
try { await ensureShortMessagesTable(); } catch (err) { console.error('Short messages table:', err); }
|
||||
try { await ensureFeedbacksTable(); } catch (err) { console.error('Feedbacks table:', err); }
|
||||
try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
|
||||
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();
|
||||
try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); }
|
||||
@@ -8805,4 +9249,15 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
});
|
||||
lessonAiChecker.start();
|
||||
console.log('Lesson report AI worker started');
|
||||
feedbackWorker = createFeedbackWriter({
|
||||
pool,
|
||||
getSetting,
|
||||
logAudit,
|
||||
aiUrl: AI_URL,
|
||||
defaultPrompt: FEEDBACK_AI_DEFAULT_PROMPT,
|
||||
bus: createWorkerBus(FEEDBACK_WAKE_CHANNEL),
|
||||
onDone: onFeedbackDone,
|
||||
});
|
||||
feedbackWorker.start();
|
||||
console.log('Feedback AI worker started');
|
||||
})();
|
||||
|
||||
@@ -1051,4 +1051,302 @@ async function loop() {
|
||||
return { start, stop, notify, getInfo };
|
||||
}
|
||||
|
||||
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, lessonTopicPosition };
|
||||
function createFeedbackWriter(opts) {
|
||||
const pool = opts.pool;
|
||||
const getSetting = opts.getSetting;
|
||||
const logAudit = opts.logAudit;
|
||||
const aiUrl = (opts.aiUrl || process.env.AI_URL || 'http://text-corrector:8080');
|
||||
const defaultPrompt = opts.defaultPrompt || '';
|
||||
const bus = opts.bus;
|
||||
const onDone = opts.onDone || (() => {});
|
||||
|
||||
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||
const TIMEOUT_MS = opts.timeoutMs || parseInt(process.env.FEEDBACK_AI_TIMEOUT_MS || '120000', 10) || 120000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const IDLE_MIN_MS = 500;
|
||||
const IDLE_MAX_MS = 30000;
|
||||
const MIN_CHARS = 1;
|
||||
const MAX_INPUT_CHARS = 6000;
|
||||
const TEXT_MAX = 5000;
|
||||
|
||||
let running = false;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let idleMs = IDLE_MIN_MS;
|
||||
let inFlight = false;
|
||||
|
||||
function notify() {
|
||||
if (!running || inFlight) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(tick, 50);
|
||||
}
|
||||
|
||||
if (bus && typeof bus.subscribe === 'function') {
|
||||
try { bus.subscribe(notify); } catch (e) {}
|
||||
}
|
||||
|
||||
async function getSettingValue(key, def) {
|
||||
try {
|
||||
const v = await getSetting(key, def);
|
||||
return v;
|
||||
} catch (e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveActiveProfile(profileId) {
|
||||
try {
|
||||
const active = await getSettingValue('ai_active_profile', 'native');
|
||||
const profilesRaw = await getSettingValue('ai_profiles', '[]');
|
||||
let profiles = [];
|
||||
try { profiles = JSON.parse(profilesRaw); } catch (e) {}
|
||||
if (profileId) {
|
||||
const p = profiles.find(x => String(x.id) === String(profileId));
|
||||
if (p) return { type: 'profile', profile: p };
|
||||
}
|
||||
if (String(active) !== 'native') {
|
||||
const p = profiles.find(x => String(x.id) === String(active));
|
||||
if (p) return { type: 'profile', profile: p };
|
||||
}
|
||||
return { type: 'native', model: AI_MODEL, baseUrl: aiUrl };
|
||||
} catch (e) {
|
||||
return { type: 'native', model: AI_MODEL, baseUrl: aiUrl };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpenAiBase(baseUrl) {
|
||||
const b = (baseUrl || '').replace(/\/$/, '');
|
||||
return b.includes('/v1') ? b : `${b}/v1`;
|
||||
}
|
||||
|
||||
async function callModel(systemPrompt, userText, profileId) {
|
||||
const text = String(userText || '');
|
||||
if (text.length > MAX_INPUT_CHARS) {
|
||||
return text.slice(0, TEXT_MAX);
|
||||
}
|
||||
const resolved = await resolveActiveProfile(profileId);
|
||||
const controller = new AbortController();
|
||||
const timerAbort = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
let url, headers, body;
|
||||
if (resolved.type === 'profile') {
|
||||
const p = resolved.profile;
|
||||
const base = normalizeOpenAiBase(p.base_url || aiUrl);
|
||||
url = `${base}/chat/completions`;
|
||||
headers = { 'Content-Type': 'application/json' };
|
||||
if (p.api_key) headers.Authorization = `Bearer ${p.api_key}`;
|
||||
body = {
|
||||
model: p.model || AI_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text }
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: p.max_tokens || Math.min(2048, Math.max(256, text.length * 2))
|
||||
};
|
||||
} else {
|
||||
const base = normalizeOpenAiBase(resolved.baseUrl || aiUrl);
|
||||
url = `${base}/chat/completions`;
|
||||
headers = { 'Content-Type': 'application/json' };
|
||||
body = {
|
||||
model: resolved.model || AI_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text }
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: Math.min(2048, Math.max(256, text.length * 2))
|
||||
};
|
||||
}
|
||||
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal });
|
||||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content?.trim() || '';
|
||||
} finally {
|
||||
clearTimeout(timerAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutput(s) {
|
||||
let out = String(s || '').trim();
|
||||
if (out.startsWith('```') && out.endsWith('```')) {
|
||||
out = out.slice(3, -3).trim();
|
||||
}
|
||||
out = out.replace(/^(Итоговый текст:|Отзыв:)\s*/i, '');
|
||||
out = out.replace(/^"|"$/g, '');
|
||||
out = out.replace(/\n{3,}/g, '\n\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
async function isEnabled() {
|
||||
try {
|
||||
const v = await getSettingValue('feedback_ai_enabled', 'true');
|
||||
return String(v) !== 'false';
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function claimNext() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const q = `SELECT f.*, g.name AS group_name, g.branch_id AS group_branch_id
|
||||
FROM feedbacks f
|
||||
LEFT JOIN groups g ON g.id = f.group_id
|
||||
WHERE f.ai_status = 'pending' AND f.deleted_at IS NULL
|
||||
ORDER BY f.id ASC
|
||||
FOR UPDATE OF f SKIP LOCKED
|
||||
LIMIT 1`;
|
||||
const r = await client.query(q);
|
||||
if (r.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return null;
|
||||
}
|
||||
const row = r.rows[0];
|
||||
await client.query(`UPDATE feedbacks SET ai_status='processing', updated_at=now() WHERE id=$1`, [row.id]);
|
||||
await client.query('COMMIT');
|
||||
row.branch_id = row.branch_id ?? row.group_branch_id ?? null;
|
||||
return row;
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch (e2) {}
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function processOne(row) {
|
||||
if (row.text && String(row.text).trim().length >= MIN_CHARS) {
|
||||
// allow overwrite only if empty? keep as done? but generate on request
|
||||
}
|
||||
let context = '';
|
||||
context += `Группа: ${row.group_name || ''}\n`;
|
||||
context += `Дата занятия: ${row.feedback_date || ''}\n`;
|
||||
context += `Резидент: ${row.resident || ''}\n`;
|
||||
context += `Тема занятия: ${row.topic || ''}\n`;
|
||||
context += `Прошлый отзыв: ${row.past_review || 'нет'}\n`;
|
||||
let shorts = [];
|
||||
try {
|
||||
const ids = Array.isArray(row.short_message_ids) ? row.short_message_ids : [];
|
||||
if (ids.length) {
|
||||
const sr = await pool.query(`SELECT resident, lesson_date, topic, message FROM short_messages WHERE id = ANY($1::int[]) AND deleted_at IS NULL ORDER BY id`, [ids]);
|
||||
shorts = sr.rows;
|
||||
}
|
||||
} catch (e) {}
|
||||
if (shorts.length) {
|
||||
context += 'Короткие сообщения (выбраны):\n';
|
||||
for (const s of shorts) {
|
||||
context += `- ${s.lesson_date || ''} – ${s.topic || '-'} – ${s.message || ''}\n`;
|
||||
}
|
||||
}
|
||||
const systemPrompt = defaultPrompt + (context ? '\n\nКОНТЕКСТ:\n' + context : '');
|
||||
let out = await callModel(systemPrompt, row.text && row.text.trim() ? row.text : (context || 'Сформируй отзыв по контексту'), row.ai_profile || null);
|
||||
let norm = normalizeOutput(out);
|
||||
if (!norm) return { status: 'error', error: 'Пустой ответ от модели' };
|
||||
if (norm.length > TEXT_MAX) norm = norm.slice(0, TEXT_MAX);
|
||||
const oldText = (row.text || '').trim();
|
||||
if (oldText && norm.trim() === oldText) {
|
||||
return { status: 'skipped', text: oldText };
|
||||
}
|
||||
return { status: 'done', text: norm, aiText: norm };
|
||||
}
|
||||
|
||||
async function finish(row, res) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
sets.push(`ai_status=$${i++}`); params.push(res.status);
|
||||
sets.push(`ai_checked_at=now()`);
|
||||
if (res.error) { sets.push(`ai_error=$${i++}`); params.push(res.error); } else { sets.push(`ai_error=NULL`); }
|
||||
if (res.status === 'done' && res.text) { sets.push(`text=$${i++}`); params.push(res.text); }
|
||||
sets.push(`updated_at=now()`);
|
||||
params.push(row.id);
|
||||
await client.query(`UPDATE feedbacks SET ${sets.join(', ')} WHERE id=$${i}`, params);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch (e2) {}
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
try { onDone({ row, status: res.status, text: res.text || row.text, aiText: res.aiText, error: res.error }); } catch (e) {}
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (stopped || !running || inFlight) return;
|
||||
const enabled = await isEnabled();
|
||||
if (!enabled) {
|
||||
idleMs = Math.min(idleMs * 2, IDLE_MAX_MS);
|
||||
timer = setTimeout(tick, idleMs);
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
try {
|
||||
let attempts = 0;
|
||||
while (attempts < MAX_ATTEMPTS) {
|
||||
attempts++;
|
||||
const row = await claimNext();
|
||||
if (!row) break;
|
||||
let res;
|
||||
try {
|
||||
res = await processOne(row);
|
||||
} catch (e) {
|
||||
res = { status: 'error', error: e.message };
|
||||
}
|
||||
if (res.status === 'error' && attempts < MAX_ATTEMPTS) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`UPDATE feedbacks SET ai_status='pending', updated_at=now(), ai_error=$2 WHERE id=$1`, [row.id, res.error || 'error']);
|
||||
await client.query('COMMIT');
|
||||
} catch (e2) {
|
||||
try { await client.query('ROLLBACK'); } catch (e3) {}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
idleMs = IDLE_MIN_MS;
|
||||
continue;
|
||||
}
|
||||
await finish(row, res);
|
||||
idleMs = IDLE_MIN_MS;
|
||||
}
|
||||
} catch (e) {
|
||||
idleMs = Math.min(idleMs * 2, IDLE_MAX_MS);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
timer = setTimeout(tick, idleMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetStale() {
|
||||
try {
|
||||
await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL WHERE ai_status='processing'`);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
stopped = false;
|
||||
idleMs = IDLE_MIN_MS;
|
||||
resetStale().finally(() => { tick(); });
|
||||
}
|
||||
|
||||
function stop() {
|
||||
running = false;
|
||||
stopped = true;
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
}
|
||||
|
||||
function getInfo() {
|
||||
return { running, inFlight, idleMs };
|
||||
}
|
||||
|
||||
return { start, stop, notify, getInfo };
|
||||
}
|
||||
|
||||
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, createFeedbackWriter, lessonTopicPosition };
|
||||
|
||||
Reference in New Issue
Block a user