- 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>
211 lines
17 KiB
Markdown
211 lines
17 KiB
Markdown
# 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` |