feat: per-user feedback AI prompt template with placeholder keys
This commit is contained in:
@@ -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` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных
|
||||
|
||||
+8
-1
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="feedback">
|
||||
@@ -56,7 +61,10 @@
|
||||
|
||||
<div class="modal-overlay" id="feedbackModal">
|
||||
<div class="edit-modal" style="max-width:680px">
|
||||
<div class="fb-modal-head">
|
||||
<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">
|
||||
<input type="hidden" id="fbId">
|
||||
<div class="settings-stack-grid">
|
||||
@@ -122,6 +130,46 @@
|
||||
</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="js/datetime.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
+81
-1
@@ -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 = '<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() {
|
||||
@@ -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();
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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: 'Пустой ответ от модели' };
|
||||
|
||||
Reference in New Issue
Block a user