feat: per-user feedback AI prompt template with placeholder keys

This commit is contained in:
dev
2026-10-06 18:55:04 +03:00
parent 285ee84c98
commit a73b1b3f8c
8 changed files with 269 additions and 8 deletions
+13 -1
View File
@@ -35,6 +35,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю. - Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю.
### Как ставить задачу субагенту ### Как ставить задачу субагенту
- **Язык: размышления и задания субагентам — на английском.** `systemPrompt` и `task` пишутся по-английски, ответы субагента тоже ожидаем на английском. На русском агент общается **только с пользователем** (планы, уточнения, финальное резюме)
- **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять. - **Один субагент — одна узкая подзадача.** В `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`** — без этого вложения чата снесутся как «сироты» - **`sweepOrphanedUploads()` обязан включать `chat_attachments.path`** — без этого вложения чата снесутся как «сироты»
- **Бэкап**: `chat_threads`/`chat_messages`/`chat_attachments` входят в `BACKUP_TABLES` и `BACKUP_SEQUENCE_TABLES`, нормализуются в `normalizeRestoreData` и вставляются в restore с проверкой FK - **Бэкап**: `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 ### 4. API Patterns
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **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` и фильтр не добавляется - **Филиалы**: `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) - **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` - Формат архива: `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`, и здесь** - `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` - Файлы: `storage.downloadAll` кладёт в архив всё, кроме регенерируемых `.thumbs/` и `.cache/`; `.originals/` (оригиналы фото до ИИ-обработки) **входят** и восстанавливаются через `uploadTree`
- Restore: валидация всего через `normalizeRestoreData` (`backup-restore.js`), транзакция с `DELETE` в FK-безопасном порядке → `INSERT` → `setval` по `BACKUP_SEQUENCE_TABLES` → файлы → `sweepOrphanedUploads()` → `loadBans()` → `invalidateAll()` - 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` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных - **Колонки, которые normalizeRestoreData обязана сохранять**: `groups.deleted_at`/`purge_at`, `entries.purge_at`. Потеря `deleted_at` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных
+8 -1
View File
@@ -5,6 +5,7 @@ const BACKUP_FORMAT_VERSION = 2;
const BACKUP_MIN_FORMAT_VERSION = 1; const BACKUP_MIN_FORMAT_VERSION = 1;
const BACKUP_TABLES = [ const BACKUP_TABLES = [
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches', 'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches',
'user_settings',
'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs',
'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications', 'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications',
'notification_reads', 'banned_ips', 'chat_threads', 'chat_messages', 'chat_attachments', 'notification_reads', 'banned_ips', 'chat_threads', 'chat_messages', 'chat_attachments',
@@ -537,7 +538,13 @@ function normalizeRestoreData(data) {
created_at: optTs(r.created_at), created_at: optTs(r.created_at),
updated_at: optTs(r.updated_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 = { module.exports = {
+8
View File
@@ -214,6 +214,14 @@ CREATE TABLE IF NOT EXISTS user_branches (
PRIMARY KEY (user_id, branch_id) 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 ( CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+8
View File
@@ -670,3 +670,11 @@ INSERT INTO settings (key, value) VALUES ('feedback_ai_prompt','Ты — пом
ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.') ON CONFLICT (key) DO NOTHING; ФОРМАТ ОТВЕТА: только текст отзыва, без лишних элементов.') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_feedback_ai_ready','false') 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)
);
+48
View File
@@ -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 { 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 { 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; } .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; }
</style> </style>
</head> </head>
<body data-page="feedback"> <body data-page="feedback">
@@ -56,7 +61,10 @@
<div class="modal-overlay" id="feedbackModal"> <div class="modal-overlay" id="feedbackModal">
<div class="edit-modal" style="max-width:680px"> <div class="edit-modal" style="max-width:680px">
<div class="fb-modal-head">
<h3 id="fbModalTitle">Добавить отзыв</h3> <h3 id="fbModalTitle">Добавить отзыв</h3>
<button type="button" class="icon-btn" id="fbPromptBtn" title="Настройки ИИ-промпта"><i data-lucide="settings"></i></button>
</div>
<form id="fbForm" class="settings-stack" autocomplete="off"> <form id="fbForm" class="settings-stack" autocomplete="off">
<input type="hidden" id="fbId"> <input type="hidden" id="fbId">
<div class="settings-stack-grid"> <div class="settings-stack-grid">
@@ -122,6 +130,46 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="fbPromptModal">
<div class="edit-modal" style="max-width:720px">
<div class="fb-modal-head">
<h3>Настройки ИИ-промпта</h3>
<button type="button" class="icon-btn" id="fbPromptClose" title="Закрыть"><i data-lucide="x"></i></button>
</div>
<div class="settings-stack">
<div class="settings-field">
<label>Модель по умолчанию</label>
<select id="fbDefaultModel" class="settings-input"></select>
<div class="hint">Подставляется в поле «Модель ИИ» при добавлении отзыва. Пусто — активная модель системы</div>
</div>
<div class="settings-field">
<label>Шаблон промпта</label>
<textarea id="fbPromptText" class="settings-input" rows="14" maxlength="8000" placeholder="Загрузка…"></textarea>
<div class="hint"><span id="fbPromptCount">0 / 8000</span> · Пусто — используется системный шаблон</div>
</div>
<div class="settings-field">
<label>Ключи подстановки в шаблон</label>
<div class="fb-keys">
<div class="fb-key"><code>{group}</code><span>название группы</span></div>
<div class="fb-key"><code>{date}</code><span>дата занятия (ГГГГ-ММ-ДД)</span></div>
<div class="fb-key"><code>{resident}</code><span>фамилия и имя резидента</span></div>
<div class="fb-key"><code>{topic}</code><span>тема занятия</span></div>
<div class="fb-key"><code>{past_review}</code><span>прошлый отзыв, или «нет», если его нет</span></div>
<div class="fb-key"><code>{shorts}</code><span>выбранные короткие сообщения: «- дата – тема – текст»</span></div>
<div class="fb-key"><code>{context}</code><span>стандартный блок контекста занятия целиком: группа, дата, резидент, тема, прошлый отзыв, короткие сообщения</span></div>
<div class="fb-key"><code>{text}</code><span>черновик текста отзыва из формы</span></div>
</div>
<div class="hint">Если шаблон содержит хотя бы один ключ — данные подставляются по ним, и автоматический блок «КОНТЕКСТ» в конец промпта не добавляется. Без ключей блок «КОНТЕКСТ» добавляется в конец автоматически.</div>
</div>
<div class="ai-status" id="fbPromptStatus" hidden></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="fbPromptReset">Сбросить</button>
<button type="button" class="btn-primary" id="fbPromptSave">Сохранить</button>
</div>
</div>
</div>
</div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script> <script src="js/datetime.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
+81 -1
View File
@@ -1,7 +1,7 @@
(async function() { (async function() {
const API = location.origin; const API = location.origin;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = ''; let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '', userSettings = { settings: {}, defaults: {} }, aiActive = 'native';
const topicReportCache = new Map(); const topicReportCache = new Map();
function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; } function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; }
@@ -68,6 +68,69 @@
function closeModal() { function closeModal() {
const m = document.getElementById('feedbackModal'); const m = document.getElementById('feedbackModal');
if (m) m.classList.remove('open'); if (m) m.classList.remove('open');
closePromptModal();
}
function closePromptModal() {
const m = document.getElementById('fbPromptModal');
if (m) m.classList.remove('open');
}
function updatePromptCount() {
const el = document.getElementById('fbPromptCount');
const ta = document.getElementById('fbPromptText');
if (el && ta) el.textContent = `${ta.value.length} / 8000`;
}
async function loadUserSettings() {
try {
const r = await fetch(`${API}/api/user-settings`, { headers: hdr() });
if (!r.ok) return;
const j = await r.json();
userSettings.settings = j.settings || {};
userSettings.defaults = j.defaults || {};
} catch (e) {}
}
function openPromptModal() {
const src = document.getElementById('fbModel');
const dst = document.getElementById('fbDefaultModel');
dst.innerHTML = '<option value="">Активная модель по умолчанию</option>' + src.innerHTML;
dst.value = userSettings.settings.feedback_profile || '';
const ta = document.getElementById('fbPromptText');
ta.value = userSettings.settings.feedback_prompt || '';
ta.placeholder = userSettings.defaults.feedback_prompt || 'Системный шаблон';
document.getElementById('fbPromptStatus').hidden = true;
updatePromptCount();
document.getElementById('fbPromptModal').classList.add('open');
if (window.lucide) lucide.createIcons();
}
async function savePromptSettings() {
const st = document.getElementById('fbPromptStatus');
const body = {
settings: {
feedback_prompt: document.getElementById('fbPromptText').value.trim(),
feedback_profile: document.getElementById('fbDefaultModel').value || '',
}
};
try {
const r = await fetch(`${API}/api/user-settings`, { method: 'PUT', headers: {...hdr(), 'Content-Type': 'application/json'}, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
st.hidden = false;
st.innerHTML = `<span class="ai-badge error">${escapeHtml(j.error || 'Не удалось сохранить')}</span>`;
return;
}
userSettings.settings = j.settings || {};
userSettings.defaults = j.defaults || userSettings.defaults;
st.hidden = false;
st.innerHTML = '<span class="ai-badge done">Сохранено</span>';
setTimeout(closePromptModal, 700);
} catch (e) {
st.hidden = false;
st.innerHTML = '<span class="ai-badge error">Не удалось сохранить</span>';
}
} }
function todayIso() { function todayIso() {
@@ -170,6 +233,7 @@
const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sel.appendChild(nativeOpt); const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sel.appendChild(nativeOpt);
(d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sel.appendChild(o); }); (d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sel.appendChild(o); });
if (d.active) sel.value = d.active; if (d.active) sel.value = d.active;
aiActive = d.active || 'native';
} }
} catch (e) {} } catch (e) {}
} }
@@ -226,6 +290,10 @@
document.getElementById('fbTextHint').textContent = '0 / 5000'; document.getElementById('fbTextHint').textContent = '0 / 5000';
document.getElementById('fbAiCheck').checked = true; document.getElementById('fbAiCheck').checked = true;
document.getElementById('fbAiStatus').hidden = true; document.getElementById('fbAiStatus').hidden = true;
const ms = document.getElementById('fbModel');
const pref = userSettings.settings.feedback_profile;
if (pref && [...ms.options].some(o => o.value === pref)) ms.value = pref;
else if (aiActive && [...ms.options].some(o => o.value === aiActive)) ms.value = aiActive;
document.getElementById('fbModalTitle').textContent = 'Добавить отзыв'; document.getElementById('fbModalTitle').textContent = 'Добавить отзыв';
document.getElementById('fbSubmit').textContent = 'Отправить в очередь'; document.getElementById('fbSubmit').textContent = 'Отправить в очередь';
} }
@@ -309,6 +377,7 @@
await checkAuth().catch(()=>{}); await checkAuth().catch(()=>{});
buildSidebar('feedback'); buildSidebar('feedback');
await loadGroups(); await loadGroups();
await loadUserSettings();
await loadAiProfiles(); await loadAiProfiles();
await loadResidentNames(); await loadResidentNames();
loadTopics(); loadTopics();
@@ -316,6 +385,17 @@
connectFeedbackLive(); connectFeedbackLive();
document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({})); document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({}));
document.getElementById('fbCancel').addEventListener('click', closeModal); document.getElementById('fbCancel').addEventListener('click', closeModal);
document.getElementById('fbPromptBtn').addEventListener('click', openPromptModal);
document.getElementById('fbPromptClose').addEventListener('click', closePromptModal);
document.getElementById('fbPromptModal').addEventListener('click', e => { if (e.target.id === 'fbPromptModal') closePromptModal(); });
document.getElementById('fbPromptText').addEventListener('input', updatePromptCount);
document.getElementById('fbPromptReset').addEventListener('click', () => {
document.getElementById('fbPromptText').value = '';
document.getElementById('fbDefaultModel').value = '';
document.getElementById('fbPromptStatus').hidden = true;
updatePromptCount();
});
document.getElementById('fbPromptSave').addEventListener('click', savePromptSettings);
document.getElementById('feedbackModal').addEventListener('click', e => { if (e.target.id === 'feedbackModal') closeModal(); }); document.getElementById('feedbackModal').addEventListener('click', e => { if (e.target.id === 'feedbackModal') closeModal(); });
document.getElementById('fbForm').addEventListener('submit', async e => { document.getElementById('fbForm').addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
+71 -3
View File
@@ -1703,6 +1703,13 @@ async function ensureUserTables() {
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE, branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, branch_id) PRIMARY KEY (user_id, branch_id)
)`); )`);
await pool.query(`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)
)`);
await pool.query(`CREATE TABLE IF NOT EXISTS sessions ( await pool.query(`CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -3574,6 +3581,9 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
if (key === 'lesson_ai_prompt' && String(value).length > 8000) { if (key === 'lesson_ai_prompt' && String(value).length > 8000) {
return res.status(400).json({ error: 'lesson_ai_prompt длиннее 8000 символов' }); return res.status(400).json({ error: 'lesson_ai_prompt длиннее 8000 символов' });
} }
if (key === 'feedback_ai_prompt' && String(value).length > 8000) {
return res.status(400).json({ error: 'feedback_ai_prompt длиннее 8000 символов' });
}
if (key === 'timezone' && !validTimezone(String(value).trim())) { if (key === 'timezone' && !validTimezone(String(value).trim())) {
return res.status(400).json({ error: 'timezone должен быть корректным часовым поясом IANA, например Europe/Moscow' }); return res.status(400).json({ error: 'timezone должен быть корректным часовым поясом IANA, например Europe/Moscow' });
} }
@@ -3609,6 +3619,53 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
} }
}); });
// --- User settings (персональные настройки пользователя) ---
const USER_SETTINGS_KEYS = new Set(['feedback_prompt', 'feedback_profile']);
const USER_SETTINGS_LIMITS = { feedback_prompt: 8000, feedback_profile: 100 };
async function userSettingsPayload(userId) {
const { rows } = await pool.query('SELECT key, value FROM user_settings WHERE user_id = $1', [userId]);
const settings = {};
rows.forEach(r => { if (USER_SETTINGS_KEYS.has(r.key)) settings[r.key] = r.value; });
const globalPrompt = await getSetting('feedback_ai_prompt', FEEDBACK_AI_DEFAULT_PROMPT);
return { settings, defaults: { feedback_prompt: globalPrompt || FEEDBACK_AI_DEFAULT_PROMPT } };
}
app.get('/api/user-settings', requireAuth, async (req, res) => {
res.json(await userSettingsPayload(req.user.id));
});
app.put('/api/user-settings', requireAuth, async (req, res) => {
const incoming = req.body && req.body.settings;
if (!incoming || typeof incoming !== 'object') return res.status(400).json({ error: 'settings required' });
const entries = [];
for (const [key, raw] of Object.entries(incoming)) {
if (!USER_SETTINGS_KEYS.has(key)) return res.status(400).json({ error: `Неизвестный ключ: ${key}` });
const value = raw == null ? '' : String(raw).trim();
if (value.length > USER_SETTINGS_LIMITS[key]) {
return res.status(400).json({ error: `${key} длиннее ${USER_SETTINGS_LIMITS[key]} символов` });
}
entries.push([key, value]);
}
try {
for (const [key, value] of entries) {
if (value === '') {
await pool.query('DELETE FROM user_settings WHERE user_id = $1 AND key = $2', [req.user.id, key]);
} else {
await pool.query(
`INSERT INTO user_settings (user_id, key, value) VALUES ($1, $2, $3)
ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[req.user.id, key, value]
);
}
}
await logAudit(req, 'user_settings.update', { keys: entries.map(e => e[0]) });
res.json(await userSettingsPayload(req.user.id));
} catch (e) {
res.status(500).json({ error: 'Не удалось сохранить настройки' });
}
});
const logoUpload = upload.single('logo'); const logoUpload = upload.single('logo');
app.post('/api/settings/logo', requireAdmin, (req, res, next) => { app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
logoUpload(req, res, (err) => { logoUpload(req, res, (err) => {
@@ -3923,7 +3980,7 @@ function sweepBackupStorage() {
async function buildBackupArchive() { async function buildBackupArchive() {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try { try {
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca, sm] = await Promise.all([ const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca, sm, fb, uss] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'), pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'), pool.query('SELECT * FROM entries ORDER BY id'),
@@ -3948,6 +4005,8 @@ async function buildBackupArchive() {
pool.query('SELECT * FROM chat_messages ORDER BY id'), pool.query('SELECT * FROM chat_messages ORDER BY id'),
pool.query('SELECT * FROM chat_attachments ORDER BY id'), pool.query('SELECT * FROM chat_attachments ORDER BY id'),
pool.query('SELECT * FROM short_messages ORDER BY id'), pool.query('SELECT * FROM short_messages ORDER BY id'),
pool.query('SELECT * FROM feedbacks ORDER BY id'),
pool.query('SELECT * FROM user_settings ORDER BY user_id, key'),
]); ]);
const settings = {}; const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; }); st.rows.forEach(r => { settings[r.key] = r.value; });
@@ -3963,9 +4022,9 @@ async function buildBackupArchive() {
lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount, lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount,
notification_reads: nr.rowCount, banned_ips: bi.rowCount, notification_reads: nr.rowCount, banned_ips: bi.rowCount,
chat_threads: ct.rowCount, chat_messages: cm.rowCount, chat_attachments: ca.rowCount, chat_threads: ct.rowCount, chat_messages: cm.rowCount, chat_attachments: ca.rowCount,
short_messages: sm.rowCount, short_messages: sm.rowCount, feedbacks: fb.rowCount, user_settings: uss.rowCount,
}, },
groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows, short_messages: sm.rows, groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows, short_messages: sm.rows, feedbacks: fb.rows, user_settings: uss.rows,
}; };
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
const files = await storage.downloadAll(path.join(staging, 'uploads')); const files = await storage.downloadAll(path.join(staging, 'uploads'));
@@ -4145,6 +4204,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
await client.query('DELETE FROM sessions'); await client.query('DELETE FROM sessions');
await client.query('DELETE FROM api_keys'); await client.query('DELETE FROM api_keys');
await client.query('DELETE FROM audit_log'); await client.query('DELETE FROM audit_log');
await client.query('DELETE FROM user_settings');
await client.query('DELETE FROM users'); await client.query('DELETE FROM users');
await client.query('DELETE FROM branches'); await client.query('DELETE FROM branches');
for (const x of ndata.branches) { for (const x of ndata.branches) {
@@ -4159,6 +4219,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.username, x.password_hash, x.name, x.role, x.is_active, x.created_at] [x.id, x.username, x.password_hash, x.name, x.role, x.is_active, x.created_at]
); );
} }
for (const x of (ndata.user_settings || [])) {
const okUser = (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount;
if (!okUser) continue;
await client.query(
'INSERT INTO user_settings (user_id, key, value, updated_at) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at',
[x.user_id, x.key, x.value, x.updated_at || null]
);
}
for (const x of ndata.user_branches) { for (const x of ndata.user_branches) {
await client.query( await client.query(
'INSERT INTO user_branches (user_id, branch_id) VALUES ($1,$2)', 'INSERT INTO user_branches (user_id, branch_id) VALUES ($1,$2)',
+31 -1
View File
@@ -1216,6 +1216,22 @@ function createFeedbackWriter(opts) {
} }
} }
async function resolveFeedbackPrompt(row) {
let prompt = defaultPrompt;
try {
const base = await getSettingValue('feedback_ai_prompt', '');
if (base && base.trim()) prompt = base.trim();
} catch (e) {}
try {
if (row.author_id) {
const r = await pool.query(`SELECT value FROM user_settings WHERE user_id = $1 AND key = 'feedback_prompt'`, [row.author_id]);
const own = r.rows[0] && r.rows[0].value;
if (own && own.trim()) prompt = own.trim();
}
} catch (e) {}
return prompt;
}
async function processOne(row) { async function processOne(row) {
if (row.text && String(row.text).trim().length >= MIN_CHARS) { if (row.text && String(row.text).trim().length >= MIN_CHARS) {
// allow overwrite only if empty? keep as done? but generate on request // allow overwrite only if empty? keep as done? but generate on request
@@ -1240,7 +1256,21 @@ function createFeedbackWriter(opts) {
context += `- ${s.lesson_date || ''} – ${s.topic || '-'} – ${s.message || ''}\n`; context += `- ${s.lesson_date || ''} – ${s.topic || '-'} – ${s.message || ''}\n`;
} }
} }
const systemPrompt = defaultPrompt + (context ? '\n\nКОНТЕКСТ:\n' + context : ''); const prompt = await resolveFeedbackPrompt(row);
const keys = {
group: row.group_name || '',
date: row.feedback_date || '',
resident: row.resident || '',
topic: row.topic || '',
past_review: row.past_review || 'нет',
shorts: shorts.length ? shorts.map(s => `- ${s.lesson_date || ''} – ${s.topic || '-'} – ${s.message || ''}`).join('\n') : 'нет',
context: context,
text: (row.text || '').trim(),
};
const hasKeys = /\{(group|date|resident|topic|past_review|shorts|context|text)\}/.test(prompt);
const systemPrompt = hasKeys
? prompt.replace(/\{(\w+)\}/g, (m, k) => (Object.prototype.hasOwnProperty.call(keys, k) ? String(keys[k]) : m))
: prompt + (context ? '\n\nКОНТЕКСТ:\n' + context : '');
let out = await callModel(systemPrompt, row.text && row.text.trim() ? row.text : (context || 'Сформируй отзыв по контексту'), row.ai_profile || null); let out = await callModel(systemPrompt, row.text && row.text.trim() ? row.text : (context || 'Сформируй отзыв по контексту'), row.ai_profile || null);
let norm = normalizeOutput(out); let norm = normalizeOutput(out);
if (!norm) return { status: 'error', error: 'Пустой ответ от модели' }; if (!norm) return { status: 'error', error: 'Пустой ответ от модели' };