Files
WhatIDo/FEEDBACK_TODO.md
T
devandopenhands 98f24faaaf 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>
2026-10-06 13:10:14 +03:00

211 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`