- 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>
17 KiB
17 KiB
TODO: Раздел «Обратная связь» (feedbacks)
Цель: добавить новый раздел «Обратная связь» по образцу lessons, но с отдельным воркером и отдельной таблицей feedbacks.
Принятые решения
- Имя сущности:
feedbacks(API/api/feedbacks, страницаpublic/feedback.html, JSpublic/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 KEYfeedback_date DATE NOT NULLgroup_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADEresident VARCHAR(200) NOT NULLtopic VARCHAR(500)past_review TEXTshort_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 TIMESTAMPTZai_error TEXTai_profile VARCHAR(100)author_id INT REFERENCES users(id) ON DELETE SET NULLbranch_id INT REFERENCES branches(id) ON DELETE SET NULLdeleted_at TIMESTAMPTZ,purge_at TIMESTAMPTZcreated_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(), publishfeedback_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()— SELECTfeedbacks fсai_status='pending' AND deleted_at IS NULL,FOR UPDATE OF f SKIP LOCKED, UPDATE →processing, вернуть строку (+JOINgroupsпри желании)processOne(row): собрать контекст (Группа/Дата/Резидент/Тема/Прошлый отзыв), загрузить выбранныеshort_messagesпоshort_message_ids(толькоdeleted_at IS NULL), форматировать блок, вызвать модель сprofileId = row.ai_profile(фолбек на активный),normalizeOutput, принять решениеdone|skipped|errorfinish()— UPDATEfeedbacks(ai_status,ai_checked_at,ai_error,textприdone,updated_at)resetStale()—processing→pendingloop()с backoff,start/stop/getInfo()
- Параметры:
- Добавить
createFeedbackWriterвmodule.exports(~1054) - В
server.jsbootstrap после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— с JOINgroups, проверка филиалаPOST /api/feedbacks— валидация полей (датаYYYY-MM-DD,residentmax 200,topicmax 500,textmax 5000,group_idint,past_reviewmax 2000,short_message_idsмассив int уникальные,ai_checkboolean,ai_profileopt). Проверка доступа к группе. INSERT. Еслиai_check && feedback_ai_enabled!=='false'→ai_status='pending',text=text||''. Пометить выбранные short_messagesused=true, used_at=CURRENT_DATE(толькоdeleted_at IS NULL, можноAND used=false).logAudit('feedback.create', {..., short_count}).invalidateFeedbacks()+invalidateShorts(). Еслиpending→wakeFeedbackWorker(). 201PUT /api/feedbacks/:id— редактирование по образцу, без сменыai_statusпо умолчанию.logAudit,invalidateFeedbacks()DELETE /api/feedbacks/:id— мягкое удаление (deleted_at,purge_at = now()+trash_purge_days) по образцуshort_messages:2794–2810PUT /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(JOINgroups 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:falseensureFeedbacksTable()уже добавил сид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_atserver.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с фильтрами, рендер +renderPagerloadGroups()— использоватьloadLessonGroups()изadmin.jsensureFeedbackModal()— динамическая модалка с полями:#fbId,#fbDate(defaulttodayIso()),#fbGroup,#fbResident,#fbTopic,#fbPastCheck,#fbPastReviewWrap,#fbPastReview,#fbShortsWrap,#fbShortsList,#fbText,#fbAiRow,#fbAiCheck,#fbModel,#fbAiStatus- Дебаунс ввода
#fbResident(>=2) →fetchShorts→ рендер чекбоксов все отмечены #fbPastChecktoggle
- Дебаунс ввода
fetchShorts(q)—GET /api/shorts?search=...&limit=50&deleted=0, кэш 5–10сrenderShortsList(items)— чекбоксыname="fbShort"всеchecked=trueopenFeedbackModal({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, wakewakeFeedbackWorker(), SSEfeedbacks_changed/feedback_status, уведомлениеfeedback.ai.ready, per-rowai_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в normalizeRestoreDatadocker compose down && docker compose up -d --builddocker 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