From a73b1b3f8c96dd4b684679f7e4e8dd37f8b65188 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 6 Oct 2026 18:55:04 +0300 Subject: [PATCH] feat: per-user feedback AI prompt template with placeholder keys --- AGENTS.md | 14 +++++++- backup-restore.js | 9 ++++- db/init.sql | 8 +++++ db/migration.sql | 8 +++++ public/feedback.html | 50 +++++++++++++++++++++++++- public/js/feedback.js | 82 ++++++++++++++++++++++++++++++++++++++++++- server.js | 74 ++++++++++++++++++++++++++++++++++++-- worker.js | 32 ++++++++++++++++- 8 files changed, 269 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7111a3f..343d65e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю. ### Как ставить задачу субагенту +- **Язык: размышления и задания субагентам — на английском.** `systemPrompt` и `task` пишутся по-английски, ответы субагента тоже ожидаем на английском. На русском агент общается **только с пользователем** (планы, уточнения, финальное резюме) - **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять. - **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком. - **Независимые разведки запускать параллельно**, а не последовательно. @@ -185,6 +186,17 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **`sweepOrphanedUploads()` обязан включать `chat_attachments.path`** — без этого вложения чата снесутся как «сироты» - **Бэкап**: `chat_threads`/`chat_messages`/`chat_attachments` входят в `BACKUP_TABLES` и `BACKUP_SEQUENCE_TABLES`, нормализуются в `normalizeRestoreData` и вставляются в restore с проверкой FK +### 3h. Персональные настройки и промпт отзыва (`server.js`, `worker.js`, `public/feedback.html`, `public/js/feedback.js`) + +- **Таблица `user_settings`** (`user_id`, `key`, `value`, `updated_at`, `PK(user_id, key)`, FK на `users` с `ON DELETE CASCADE`) — персональные настройки пользователя. Схема в трёх местах: `db/init.sql`, `db/migration.sql` и `ensureUserTables()` в `server.js` +- **Эндпоинты `GET`/`PUT /api/user-settings`** под `requireAuth` — это **не** admin-only `PUT /api/settings`, глобальную таблицу `settings` они не трогают. Allowlist ключей: `feedback_prompt` (≤8000 символов) и `feedback_profile` (≤100); неизвестный ключ — 400; пустое значение удаляет строку (семантика «как в системе»). Аудит `user_settings.update` +- **Цепочка промпта отзыва** (`resolveFeedbackPrompt` в `worker.js`): персональный `user_settings.feedback_prompt` по `feedbacks.author_id` → глобальный `feedback_ai_prompt` → `FEEDBACK_AI_DEFAULT_PROMPT`. Шаблон индивидуален для каждого пользователя и применяется ко всем отзывам, которые он авторит +- **Ключи подстановки**: `{group}`, `{date}`, `{resident}`, `{topic}`, `{past_review}`, `{shorts}`, `{context}`, `{text}`. Если шаблон содержит хотя бы один из них — значения подставляются по ключам и блок `КОНТЕКСТ:` в конец **не** добавляется; без ключей — добавляется (поведение до появления ключей). Набор ключей продублирован в трёх местах: `worker.js` (regex + карта `keys`), `public/feedback.html` (`#fbPromptModal`, список с описаниями) и здесь — при изменении набора править все три +- **UI**: шестерёнка `#fbPromptBtn` в шапке `#feedbackModal`, модалка `#fbPromptModal` («Модель по умолчанию» → `feedback_profile`, шаблон → `feedback_prompt`, описание ключей, счётчик 8000, «Сбросить» очищает оба поля). Персональная модель применяется в `resetForm()` к `#fbModel`, пустое значение = активная модель системы +- **Бэкап**: `user_settings` входит в `BACKUP_TABLES`, дамп в `buildBackupArchive`, нормализацию в `normalizeRestoreData`, `DELETE` перед `DELETE FROM users` и `INSERT` после `users` в restore (с проверкой существования пользователя) + +--- + ### 4. API Patterns - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется @@ -223,7 +235,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level) - Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore` - `data.json` содержит `version`, `created_at`, `app` (версия/коммит), `counts` (строки по таблицам + `files`) и сами данные. Таблицы перечислены в `BACKUP_TABLES` — **при добавлении таблицы править её и в `buildBackupArchive`, и здесь** -- `sessions` в бэкап **не входит** намеренно: после restore все токены должны умереть. `audit_log`, `notifications`, `notification_reads`, `banned_ips` — входят +- `sessions` в бэкап **не входит** намеренно: после restore все токены должны умереть. `audit_log`, `notifications`, `notification_reads`, `banned_ips`, `user_settings` — входят - Файлы: `storage.downloadAll` кладёт в архив всё, кроме регенерируемых `.thumbs/` и `.cache/`; `.originals/` (оригиналы фото до ИИ-обработки) **входят** и восстанавливаются через `uploadTree` - Restore: валидация всего через `normalizeRestoreData` (`backup-restore.js`), транзакция с `DELETE` в FK-безопасном порядке → `INSERT` → `setval` по `BACKUP_SEQUENCE_TABLES` → файлы → `sweepOrphanedUploads()` → `loadBans()` → `invalidateAll()` - **Колонки, которые normalizeRestoreData обязана сохранять**: `groups.deleted_at`/`purge_at`, `entries.purge_at`. Потеря `deleted_at` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных diff --git a/backup-restore.js b/backup-restore.js index 4819a78..398755e 100644 --- a/backup-restore.js +++ b/backup-restore.js @@ -5,6 +5,7 @@ const BACKUP_FORMAT_VERSION = 2; const BACKUP_MIN_FORMAT_VERSION = 1; const BACKUP_TABLES = [ 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches', + 'user_settings', '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', @@ -537,7 +538,13 @@ function normalizeRestoreData(data) { 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 }; + const user_settings = (data.user_settings || []).map(x => ({ + user_id: reqInt(x.user_id), + key: reqStr(x.key, 100), + value: optText(x.value, 8000), + updated_at: optTs(x.updated_at), + })); + return { groups, students, entries, project_files, settings, branches, users, user_branches, user_settings, 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 = { diff --git a/db/init.sql b/db/init.sql index 6fbef17..beea2fd 100644 --- a/db/init.sql +++ b/db/init.sql @@ -214,6 +214,14 @@ CREATE TABLE IF NOT EXISTS user_branches ( PRIMARY KEY (user_id, branch_id) ); +CREATE TABLE IF NOT EXISTS user_settings ( + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + updated_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (user_id, key) +); + CREATE TABLE IF NOT EXISTS sessions ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/db/migration.sql b/db/migration.sql index db576cf..f10b0bb 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -670,3 +670,11 @@ INSERT INTO settings (key, value) VALUES ('feedback_ai_prompt','Ты — пом ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.') 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 user_settings ( + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + updated_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (user_id, key) +); diff --git a/public/feedback.html b/public/feedback.html index 538faba..032650e 100644 --- a/public/feedback.html +++ b/public/feedback.html @@ -12,6 +12,11 @@ .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; } + .fb-modal-head { display:flex; align-items:center; justify-content:space-between; gap:8px; } + .fb-modal-head h3 { margin:0; } + .fb-keys { display:flex; flex-direction:column; gap:6px; } + .fb-keys .fb-key { display:flex; gap:10px; align-items:baseline; font-size:13px; line-height:1.35; } + .fb-keys code { background:rgba(127,127,127,.12); border:1px solid var(--border); border-radius:6px; padding:1px 6px; font-size:12px; white-space:nowrap; } @@ -56,7 +61,10 @@