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

17 KiB
Raw Blame History

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) добавить вызов:
    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