diff --git a/AGENTS.md b/AGENTS.md index 343d65e..4197932 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,10 +189,16 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo ### 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` +- **Эндпоинты `GET`/`PUT /api/user-settings`** под `requireAuth` — это **не** admin-only `PUT /api/settings`, глобальную таблицу `settings` они не трогают. Allowlist ключей: `feedback_prompt` (≤8000 символов), `feedback_profile` (≤100) и `feedback_models` (≤16000, структурная валидация); неизвестный ключ — 400; пустое значение удаляет строку (семантика «как в системе»). Аудит `user_settings.update` +- **Личные модели (`feedback_models`)**: JSON-массив `{ id, name, base_url, model, api_key, max_tokens }` в `user_settings`, до 10 штук; валидация — `normalizeFeedbackModels()` в `server.js` (id `^[\w-]+$` ≤40 и без дублей, name 1–64, model 1–100, base_url только http/https ≤300, api_key ≤256, max_tokens 16–131072 или null), ошибка — 400. UI — блок «Мои модели» в `#fbPromptModal` (`#fbModelsList` + форма `#fbModelEdit`), правки сохраняются вместе с «Сохранить» в `savePromptSettings()`. Селекты `#fbModel` и `#fbDefaultModel` собираются с optgroup «Мои модели» **первым**, затем «Системные» (`loadAiProfiles()`) — личные имеют приоритет, системные (`ai_profiles`/native) остаются фолбэком. Резолв в feedback-воркере: `resolveActiveProfile(profileId, authorId)` → личные модели автора (`getPersonalModels` по `feedbacks.author_id`) → системные `ai_profiles` → активная → native +- **Наполнение селектов — только `GET /api/ai/profiles/options`** (`requireAuth`, отдаёт `active`, `native{id,name,model}` и `profiles[{id,name,model}]` **без** `base_url`/`api_key`). `GET /api/ai/profiles` остаётся `requireAdmin` (в нём есть `api_key`) — на него тьютор получает 403, и раньше `loadAiProfiles()` молча оставлял селекты пустыми: `savePromptSettings()` отправлял `feedback_profile: ''`, сервер удалял строку в `user_settings`, и выбор модели «не сохранялся в БД». Тот же эндпоинт используется в `public/js/shorts.js` (`openShortsAi()`) +- **Сохранённое значение не должно теряться**: в `openPromptModal()` → `fillDefaultModelOptions()` значение из `user_settings`, отсутствующее в опциях (удалённый/переименованный профиль), добавляется как опция «… (недоступна)» — без неё `dst.value = X` молча обнулялся, и следующее сохранение стирало `feedback_profile`. После добавления/удаления личной модели в модалке селекты пересобираются (`refreshModelSelects()`), иначе новая модель недоступна как «Модель по умолчанию» до перезагрузки страницы - **Цепочка промпта отзыва** (`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`, пустое значение = активная модель системы +- **UI**: шестерёнка `#fbPromptBtn` в шапке `#feedbackModal`, модалка `#fbPromptModal` («Модель по умолчанию» → `feedback_profile`, шаблон → `feedback_prompt`, описание ключей, счётчик 8000, «Сбросить» очищает оба поля). Персональная модель применяется в `resetForm()` к `#fbModel`, пустое значение = активная модель системы. Селект `#fbModel` отправляется как `ai_profile` и при создании, и при редактировании; `PUT /api/feedbacks/:id` сохраняет его (раньше терял), а при редактировании `loadFeedback()` подставляет модель из записи +- **Учёт запуска ИИ**: колонки `feedbacks.ai_prompt` (итоговый system-промпт после подстановки ключей), `ai_model` (имя модели, реально вызванной через `callModel`), `ai_duration_ms` (время ответа). Пишет их `finish()` воркера; схема — в `db/init.sql`, `db/migration.sql`, `ensureFeedbacksTable()`, SELECT `GET /api/feedbacks` и restore-INSERT +- **Кнопка «ИИ-запрос»** на карточке отзыва (`data-aiinfo` + поповер `.fb-ai-pop` в `public/js/feedback.js`): показывает статус, модель, время ответа, время проверки, ошибку и отправленный промпт. Появляется при `ai_status !== 'none'` или наличии `ai_prompt`; попадает в разметку `fbItemHtml()`, закрывается по клику вне/Escape +- **Аудит запуска**: воркер пишет `feedback.ai.run` (id, status, attempt, model, profile, duration_ms, prompt ≤12000, input ≤6000, output ≤6000, changed, len_before/len_after) или `feedback.ai.error` (id, attempt, error ≤500, prompt, input) — `feedback.ai.generate` из `onFeedbackDone` удалён как дублирующий. `PUT /api/feedbacks/:id` пишет `feedback.update` (id, ai_check, ai_profile); `feedback.create` включает `ai_profile` - **Бэкап**: `user_settings` входит в `BACKUP_TABLES`, дамп в `buildBackupArchive`, нормализацию в `normalizeRestoreData`, `DELETE` перед `DELETE FROM users` и `INSERT` после `users` в restore (с проверкой существования пользователя) --- diff --git a/API.md b/API.md index d3630e4..75669e3 100644 --- a/API.md +++ b/API.md @@ -1873,6 +1873,14 @@ off = parseInt(offset, 10); if (off > 0) { OFFSET $n } - **Ошибки:** 401/403 — от `requireAdmin` - **Примечания:** профили лежат в `settings.ai_profiles` как JSON-массив (`getAiProfiles()`); `api_key` профиля возвращается в ответе (см. «?») +### `GET /api/ai/profiles/options` + +- **Доступ:** `requireAuth` — любой авторизованный пользователь, включая тьютора; **филиалы:** не применимо +- **Тело:** нет +- **Ответ 200:** `{ active, native: { id:'native', name:'Нативная (llama.cpp в Docker)', model: AI_MODEL }, profiles: [{ id, name, model }] }` — **без** `base_url` и `api_key` +- **Ошибки:** 401 — от `requireAuth` +- **Примечания:** лёгкий вариант `GET /api/ai/profiles` для наполнения селектов моделей на страницах, доступных тьюторам (`public/js/feedback.js` → `loadAiProfiles()`, `public/js/shorts.js` → `openShortsAi()`). Полная версия остаётся `requireAdmin`, потому что в ней отдаётся `api_key`; не заменяйте её в админ-формах + ### `POST /api/ai/profiles` - **Доступ:** `requireAdmin`; **филиалы:** не применимо @@ -2547,6 +2555,7 @@ off = parseInt(offset, 10); if (off > 0) { OFFSET $n } | `DELETE /api/entries/:id/photo/enhance-ai/preview` | 34. | | `POST /api/ai/correct` | 35. | | `GET /api/ai/profiles` | 35. | +| `GET /api/ai/profiles/options` | 35. | | `POST /api/ai/profiles` | 35. | | `PUT /api/ai/profiles/:id` | 35. | | `DELETE /api/ai/profiles/:id` | 35. | diff --git a/FEEDBACK_TODO.md b/FEEDBACK_TODO.md index 9fc8dd4..cd6e9d3 100644 --- a/FEEDBACK_TODO.md +++ b/FEEDBACK_TODO.md @@ -110,9 +110,9 @@ - [ ] `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) +- [ ] `DELETE /api/feedbacks/:id` — мягкое удаление (`deleted_at`, `purge_at = now()+trash_purge_days`) по образцу `short_messages:2794–2810`, `feedbackById` (филиал), 404 если уже удалён, `logAudit('feedback.delete')` +- [ ] `PUT /api/feedbacks/:id/restore` (requireAuth + `feedbackById`) — снять `deleted_at`, `purge_at`, 404 если не удалён, `logAudit('feedback.restore')` +- [ ] `DELETE /api/feedbacks/:id/permanent` (requireAuth + `feedbackById`) — немедленный `DELETE FROM feedbacks WHERE deleted_at IS NOT NULL`, 404 если не удалён, `logAudit('feedback.permanent-delete')`. Мягко удалённые чистит `purgeScheduledDeletions()` по `purge_at` - [ ] `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`) @@ -168,7 +168,7 @@ - [ ] `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()` +- [ ] `initFeedback()` — `checkAuth()`, `buildSidebar('feedback')`, `loadGroups()`, загрузка профилей ИИ в `#fbModel` (`GET /api/ai/profiles/options`, первый `native`), `loadFeedbacks()`, `connectFeedbackLive()`, `lucide.createIcons()` --- diff --git a/backup-restore.js b/backup-restore.js index 398755e..5ea17f2 100644 --- a/backup-restore.js +++ b/backup-restore.js @@ -530,6 +530,9 @@ function normalizeRestoreData(data) { ai_checked_at: optTs(r.ai_checked_at), ai_error: optStr(r.ai_error), ai_profile: optStr(r.ai_profile), + ai_prompt: optText(r.ai_prompt, 100000), + ai_model: optStr(r.ai_model, 200), + ai_duration_ms: optInt(r.ai_duration_ms, 0, 86400000), sent_at: optTs(r.sent_at), author_id: optInt(r.author_id), branch_id: optInt(r.branch_id), diff --git a/db/init.sql b/db/init.sql index beea2fd..3d3ecce 100644 --- a/db/init.sql +++ b/db/init.sql @@ -529,6 +529,9 @@ CREATE TABLE IF NOT EXISTS feedbacks ( ai_checked_at TIMESTAMPTZ, ai_error TEXT, ai_profile VARCHAR(100), + ai_prompt TEXT, + ai_model VARCHAR(200), + ai_duration_ms INT, sent_at TIMESTAMPTZ, author_id INT REFERENCES users(id) ON DELETE SET NULL, branch_id INT REFERENCES branches(id) ON DELETE SET NULL, diff --git a/db/migration.sql b/db/migration.sql index f10b0bb..fc9c0a4 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -631,6 +631,9 @@ ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DE ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ; ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT; ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100); +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_prompt TEXT; +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_model VARCHAR(200); +ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_duration_ms INT; ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ; ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL; diff --git a/public/feedback.html b/public/feedback.html index 032650e..17e222d 100644 --- a/public/feedback.html +++ b/public/feedback.html @@ -17,6 +17,26 @@ .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; } + .fb-keys-details { border:1px solid var(--border); border-radius:10px; background:rgba(127,127,127,.05); } + .fb-keys-details summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:8px; padding:10px 14px; font-size:.86rem; font-weight:600; color:var(--text); user-select:none; } + .fb-keys-details summary::-webkit-details-marker { display:none; } + .fb-keys-details summary::before { content:''; width:7px; height:7px; flex:none; border-right:2px solid var(--muted); border-bottom:2px solid var(--muted); transform:rotate(-45deg); transition:transform .15s; margin-left:2px; } + .fb-keys-details[open] summary::before { transform:rotate(45deg); } + .fb-keys-details[open] summary { border-bottom:1px solid var(--border); } + .fb-keys-details .fb-keys-body { display:flex; flex-direction:column; gap:10px; padding:12px 14px; } + #fbPromptText { min-height:48vh; resize:vertical; } + #fbPromptModal { overflow:auto; align-items:flex-start; padding:4vh 16px; } + #fbPromptModal > .edit-modal { margin:auto; } + .feedback-item { position:relative; } + .fb-ai-pop { position:absolute; left:12px; right:12px; top:calc(100% + 6px); z-index:40; display:flex; flex-direction:column; gap:8px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); box-shadow:0 14px 34px rgba(0,0,0,.28); padding:14px 16px; } + .fb-ai-pop[hidden] { display:none; } + .fb-ai-row { display:flex; align-items:center; gap:10px; font-size:.85rem; flex-wrap:wrap; } + .fb-ai-row > span { color:var(--muted); min-width:118px; } + .fb-ai-row .ai-badge { margin-left:0; } + .fb-ai-label { font-size:.75rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; } + .fb-ai-pop pre { margin:0; max-height:240px; overflow:auto; white-space:pre-wrap; word-break:break-word; font-size:12px; line-height:1.45; background:rgba(127,127,127,.08); border:1px solid var(--border); border-radius:8px; padding:8px 10px; } + .fb-ai-err { font-size:.83rem; line-height:1.4; color:#ef4444; background:rgba(239,68,68,.08); border:1px solid rgba(239,68,68,.3); border-radius:8px; padding:8px 10px; white-space:pre-wrap; } + .fb-ai-empty { font-size:.83rem; color:var(--muted); } @@ -60,7 +80,7 @@