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 @@
-
Добавить отзыв
+
+
Добавить отзыв
+
+
+
+
+
+
Настройки ИИ-промпта
+
+
+
+
+
+
+
Подставляется в поле «Модель ИИ» при добавлении отзыва. Пусто — активная модель системы
+
+
+
+
+
0 / 8000 · Пусто — используется системный шаблон
+
+
+
+
+
{group}название группы
+
{date}дата занятия (ГГГГ-ММ-ДД)
+
{resident}фамилия и имя резидента
+
{topic}тема занятия
+
{past_review}прошлый отзыв, или «нет», если его нет
+
{shorts}выбранные короткие сообщения: «- дата – тема – текст»
+
{context}стандартный блок контекста занятия целиком: группа, дата, резидент, тема, прошлый отзыв, короткие сообщения
+
{text}черновик текста отзыва из формы
+
+
Если шаблон содержит хотя бы один ключ — данные подставляются по ним, и автоматический блок «КОНТЕКСТ» в конец промпта не добавляется. Без ключей блок «КОНТЕКСТ» добавляется в конец автоматически.
+
+
+
+
+
+
+
+
+
+
diff --git a/public/js/feedback.js b/public/js/feedback.js
index 1122100..add9ebf 100644
--- a/public/js/feedback.js
+++ b/public/js/feedback.js
@@ -1,7 +1,7 @@
(async function() {
const API = location.origin;
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();
function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; }
@@ -68,6 +68,69 @@
function closeModal() {
const m = document.getElementById('feedbackModal');
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 = '' + 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 = `${escapeHtml(j.error || 'Не удалось сохранить')}`;
+ return;
+ }
+ userSettings.settings = j.settings || {};
+ userSettings.defaults = j.defaults || userSettings.defaults;
+ st.hidden = false;
+ st.innerHTML = 'Сохранено';
+ setTimeout(closePromptModal, 700);
+ } catch (e) {
+ st.hidden = false;
+ st.innerHTML = 'Не удалось сохранить';
+ }
}
function todayIso() {
@@ -170,6 +233,7 @@
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); });
if (d.active) sel.value = d.active;
+ aiActive = d.active || 'native';
}
} catch (e) {}
}
@@ -226,6 +290,10 @@
document.getElementById('fbTextHint').textContent = '0 / 5000';
document.getElementById('fbAiCheck').checked = 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('fbSubmit').textContent = 'Отправить в очередь';
}
@@ -309,6 +377,7 @@
await checkAuth().catch(()=>{});
buildSidebar('feedback');
await loadGroups();
+ await loadUserSettings();
await loadAiProfiles();
await loadResidentNames();
loadTopics();
@@ -316,6 +385,17 @@
connectFeedbackLive();
document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({}));
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('fbForm').addEventListener('submit', async e => {
e.preventDefault();
diff --git a/server.js b/server.js
index c86a250..2c6d09a 100644
--- a/server.js
+++ b/server.js
@@ -1703,6 +1703,13 @@ async function ensureUserTables() {
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
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 (
id SERIAL PRIMARY KEY,
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) {
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())) {
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');
app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
logoUpload(req, res, (err) => {
@@ -3923,7 +3980,7 @@ function sweepBackupStorage() {
async function buildBackupArchive() {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
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 students 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_attachments 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 = {};
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,
notification_reads: nr.rowCount, banned_ips: bi.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));
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 api_keys');
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 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]
);
}
+ 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) {
await client.query(
'INSERT INTO user_branches (user_id, branch_id) VALUES ($1,$2)',
diff --git a/worker.js b/worker.js
index 1b1ae1a..020ea63 100644
--- a/worker.js
+++ b/worker.js
@@ -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) {
if (row.text && String(row.text).trim().length >= MIN_CHARS) {
// 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`;
}
}
- 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 norm = normalizeOutput(out);
if (!norm) return { status: 'error', error: 'Пустой ответ от модели' };