feat(feedback): personal AI models, AI run tracking and profiles/options endpoint
- user_settings allowlist: feedback_models (up to 10 personal model configs) - normalizeFeedbackModels() validation, UI block "Мои модели" in #fbPromptModal - GET /api/ai/profiles/options for tutors (no api_key), used by feedback/shorts - feedbacks columns ai_prompt/ai_model/ai_duration_ms + backup/restore support - "ИИ-запрос" popover on feedback card with status, model, timing and prompt - audit: feedback.update / feedback.ai.run / feedback.ai.error labels
This commit is contained in:
@@ -1396,13 +1396,16 @@ async function purgeScheduledDeletions() {
|
||||
}
|
||||
const sres = await pool.query('DELETE FROM short_messages WHERE purge_at IS NOT NULL AND purge_at <= now()');
|
||||
const scount = sres.rowCount;
|
||||
if (erefs.rowCount || gcount || ccount || scount) {
|
||||
const fbres = await pool.query('DELETE FROM feedbacks WHERE purge_at IS NOT NULL AND purge_at <= now()');
|
||||
const fbcount = fbres.rowCount;
|
||||
if (erefs.rowCount || gcount || ccount || scount || fbcount) {
|
||||
invalidateEntries();
|
||||
invalidateGroups();
|
||||
invalidateStats();
|
||||
}
|
||||
if (scount) invalidateShorts();
|
||||
return { entries: erefs.rowCount, groups: gcount, chat_threads: ccount, short_messages: scount };
|
||||
if (fbcount) invalidateFeedbacks();
|
||||
return { entries: erefs.rowCount, groups: gcount, chat_threads: ccount, short_messages: scount, feedbacks: fbcount };
|
||||
}
|
||||
|
||||
function safeUnlink(relPath) {
|
||||
@@ -2681,6 +2684,9 @@ async function ensureFeedbacksTable() {
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
ai_profile VARCHAR(100),
|
||||
ai_prompt TEXT,
|
||||
ai_model VARCHAR(200),
|
||||
ai_duration_ms INT,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
@@ -2699,6 +2705,9 @@ async function ensureFeedbacksTable() {
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100)`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_prompt TEXT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_model VARCHAR(200)`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_duration_ms INT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL`);
|
||||
@@ -3011,7 +3020,7 @@ app.get('/api/feedbacks', requireAuth, async (req, res) => {
|
||||
params.push(limit, offset);
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM feedbacks f ${where}`, countParams);
|
||||
const items = await pool.query(
|
||||
`SELECT f.id, f.feedback_date, f.group_id, f.resident, f.topic, f.past_review, f.short_message_ids, f.text, f.ai_status, f.ai_checked_at, f.ai_error, f.ai_profile, f.sent_at, f.author_id, f.branch_id, f.deleted_at, f.purge_at, f.created_at, f.updated_at, g.name AS group_name
|
||||
`SELECT f.id, f.feedback_date, f.group_id, f.resident, f.topic, f.past_review, f.short_message_ids, f.text, f.ai_status, f.ai_checked_at, f.ai_error, f.ai_profile, f.ai_prompt, f.ai_model, f.ai_duration_ms, f.sent_at, f.author_id, f.branch_id, f.deleted_at, f.purge_at, f.created_at, f.updated_at, g.name AS group_name
|
||||
FROM feedbacks f
|
||||
LEFT JOIN groups g ON g.id = f.group_id
|
||||
${where}
|
||||
@@ -3069,7 +3078,7 @@ app.post('/api/feedbacks', requireAuth, async (req, res) => {
|
||||
await client.query(`UPDATE short_messages SET used = true, used_at = CURRENT_DATE WHERE id = ANY($1::int[]) AND deleted_at IS NULL`, [shortIds]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
await logAudit(req, 'feedback.create', { id: fb.id, group_id: groupId, resident, ai_check: aiCheck, ai_status: fb.ai_status, short_count: shortIds.length });
|
||||
await logAudit(req, 'feedback.create', { id: fb.id, group_id: groupId, resident, ai_check: aiCheck, ai_profile: aiProfile || null, ai_status: fb.ai_status, short_count: shortIds.length });
|
||||
invalidateFeedbacks();
|
||||
invalidateShorts();
|
||||
if (aiCheck) wakeFeedbackWorker();
|
||||
@@ -3252,11 +3261,19 @@ app.put('/api/feedbacks/:id', requireAuth, async (req, res) => {
|
||||
const ok = await groupBelongsToBranches(req.user, groupId);
|
||||
if (!ok) return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||
}
|
||||
const aiCheck = !!req.body?.ai_check;
|
||||
const aiProfile = optStr(req.body?.ai_profile, 100);
|
||||
await pool.query(
|
||||
`UPDATE feedbacks SET feedback_date=$1, group_id=$2, resident=$3, topic=$4, past_review=$5, short_message_ids=$6, text=$7, updated_at=now() WHERE id=$8`,
|
||||
[feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', id]
|
||||
`UPDATE feedbacks SET feedback_date=$1, group_id=$2, resident=$3, topic=$4, past_review=$5, short_message_ids=$6, text=$7, ai_profile=$8,
|
||||
ai_status = CASE WHEN $9::boolean AND ai_status IN ('none','error','done','skipped') THEN 'pending' ELSE ai_status END,
|
||||
ai_error = CASE WHEN $9::boolean AND ai_status IN ('none','error','done','skipped') THEN NULL ELSE ai_error END,
|
||||
ai_checked_at = CASE WHEN $9::boolean AND ai_status IN ('none','error','done','skipped') THEN NULL ELSE ai_checked_at END,
|
||||
updated_at=now() WHERE id=$10`,
|
||||
[feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', aiProfile || null, aiCheck, id]
|
||||
);
|
||||
invalidateFeedbacks();
|
||||
if (aiCheck) wakeFeedbackWorker();
|
||||
await logAudit(req, 'feedback.update', { id, ai_check: aiCheck, ai_profile: aiProfile || null });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -3268,23 +3285,35 @@ app.delete('/api/feedbacks/:id', requireAuth, async (req, res) => {
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const d = await pool.query('SELECT COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) AS d');
|
||||
const days = d.rows[0]?.d || 30;
|
||||
await pool.query('UPDATE feedbacks SET deleted_at=now(), purge_at = now() + ($1 || \' days\')::interval WHERE id=$2 AND deleted_at IS NULL', [days, id]);
|
||||
const upd = await pool.query('UPDATE feedbacks SET deleted_at=now(), purge_at = now() + ($1 || \' days\')::interval WHERE id=$2 AND deleted_at IS NULL', [days, id]);
|
||||
if (!upd.rowCount) return res.status(404).json({ error: 'Уже удалён' });
|
||||
await logAudit(req, 'feedback.delete', { id });
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/feedbacks/:id/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
app.put('/api/feedbacks/:id/restore', requireAuth, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
await pool.query('UPDATE feedbacks SET deleted_at=NULL, purge_at=NULL WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const upd = await pool.query('UPDATE feedbacks SET deleted_at=NULL, purge_at=NULL WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
if (!upd.rowCount) return res.status(404).json({ error: 'Не удалён' });
|
||||
await logAudit(req, 'feedback.restore', { id });
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/feedbacks/:id/permanent', requireAuth, requireAdmin, async (req, res) => {
|
||||
app.delete('/api/feedbacks/:id/permanent', requireAuth, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
await pool.query('UPDATE feedbacks SET purge_at = now() + (COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) || \' days\')::interval WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const del = await pool.query('DELETE FROM feedbacks WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
if (!del.rowCount) return res.status(404).json({ error: 'Не удалён' });
|
||||
await logAudit(req, 'feedback.permanent-delete', { id });
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
@@ -3620,8 +3649,43 @@ 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 };
|
||||
const USER_SETTINGS_KEYS = new Set(['feedback_prompt', 'feedback_profile', 'feedback_models']);
|
||||
const USER_SETTINGS_LIMITS = { feedback_prompt: 8000, feedback_profile: 100, feedback_models: 16000 };
|
||||
|
||||
function normalizeFeedbackModels(value) {
|
||||
let arr;
|
||||
try { arr = JSON.parse(value); } catch (e) { throw new Error('Некорректный JSON моделей'); }
|
||||
if (!Array.isArray(arr)) throw new Error('Ожидался список моделей');
|
||||
if (arr.length > 10) throw new Error('Не больше 10 моделей');
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const m of arr) {
|
||||
if (!m || typeof m !== 'object') throw new Error('Некорректная модель');
|
||||
const id = String(m.id || '').trim();
|
||||
const name = String(m.name || '').trim();
|
||||
const baseUrl = String(m.base_url || '').trim();
|
||||
const model = String(m.model || '').trim();
|
||||
const apiKey = String(m.api_key || '').trim();
|
||||
if (!id || id.length > 40 || !/^[\w-]+$/.test(id)) throw new Error('Некорректный id модели');
|
||||
if (!name || name.length > 64) throw new Error('Название модели: 1–64 символов');
|
||||
if (!model || model.length > 100) throw new Error('Имя модели: 1–100 символов');
|
||||
if (apiKey.length > 256) throw new Error('Ключ длиннее 256 символов');
|
||||
if (baseUrl.length > 300) throw new Error('URL длиннее 300 символов');
|
||||
try {
|
||||
const u = new URL(baseUrl);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad');
|
||||
} catch (e) { throw new Error('Некорректный base_url (нужен http/https)'); }
|
||||
let maxTokens = null;
|
||||
if (m.max_tokens != null && m.max_tokens !== '') {
|
||||
maxTokens = parseInt(m.max_tokens, 10);
|
||||
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 131072) throw new Error('max_tokens: 16–131072');
|
||||
}
|
||||
if (seen.has(id)) throw new Error('Дублирующийся id модели');
|
||||
seen.add(id);
|
||||
out.push({ id, name, base_url: baseUrl, model, api_key: apiKey, max_tokens: maxTokens });
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
async function userSettingsPayload(userId) {
|
||||
const { rows } = await pool.query('SELECT key, value FROM user_settings WHERE user_id = $1', [userId]);
|
||||
@@ -3645,6 +3709,14 @@ app.put('/api/user-settings', requireAuth, async (req, res) => {
|
||||
if (value.length > USER_SETTINGS_LIMITS[key]) {
|
||||
return res.status(400).json({ error: `${key} длиннее ${USER_SETTINGS_LIMITS[key]} символов` });
|
||||
}
|
||||
if (key === 'feedback_models' && value) {
|
||||
try {
|
||||
entries.push([key, normalizeFeedbackModels(value)]);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: e.message || 'Некорректный список моделей' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
entries.push([key, value]);
|
||||
}
|
||||
try {
|
||||
@@ -3897,9 +3969,6 @@ async function onFeedbackDone({ row, status, text, aiText, error }) {
|
||||
}).catch(() => {});
|
||||
if (status === 'done') {
|
||||
try {
|
||||
const lenBefore = row.text ? row.text.length : 0;
|
||||
const lenAfter = (aiText || '').length;
|
||||
logAudit(null, 'feedback.ai.generate', { id: row.id, len_before: lenBefore, len_after: lenAfter }).catch(() => {});
|
||||
const branchId = row.branch_id ?? null;
|
||||
pushNotification('feedback.ai.ready', { id: row.id, resident: row.resident }, branchId).catch(() => {});
|
||||
} catch (e) {}
|
||||
@@ -4369,8 +4438,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
const okUser = x.author_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.author_id])).rowCount;
|
||||
const okBranch = x.branch_id == null || (await client.query('SELECT 1 FROM branches WHERE id = $1', [x.branch_id])).rowCount;
|
||||
await client.query(
|
||||
'INSERT INTO feedbacks (id, feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_checked_at, ai_error, ai_profile, author_id, branch_id, deleted_at, purge_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)',
|
||||
[x.id, x.feedback_date, okGroup ? x.group_id : null, x.resident, x.topic, x.past_review, Array.isArray(x.short_message_ids) ? x.short_message_ids : [], x.text || '', x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.ai_profile, okUser ? x.author_id : null, okBranch ? x.branch_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at]
|
||||
'INSERT INTO feedbacks (id, feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_checked_at, ai_error, ai_profile, ai_prompt, ai_model, ai_duration_ms, author_id, branch_id, deleted_at, purge_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)',
|
||||
[x.id, x.feedback_date, okGroup ? x.group_id : null, x.resident, x.topic, x.past_review, Array.isArray(x.short_message_ids) ? x.short_message_ids : [], x.text || '', x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.ai_profile, x.ai_prompt, x.ai_model, x.ai_duration_ms, okUser ? x.author_id : null, okBranch ? x.branch_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.audit_log) {
|
||||
@@ -8168,6 +8237,16 @@ app.get('/api/ai/profiles', requireAdmin, async (_, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/ai/profiles/options', requireAuth, async (_, res) => {
|
||||
const profiles = await getAiProfiles();
|
||||
const active = await getSetting('ai_active_profile', 'native');
|
||||
res.json({
|
||||
active,
|
||||
native: { id: 'native', name: 'Нативная (llama.cpp в Docker)', model: AI_MODEL },
|
||||
profiles: profiles.map(p => ({ id: p.id, name: p.name, model: p.model })),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/ai/profiles', requireAdmin, async (req, res) => {
|
||||
const v = validateProfileBody(req.body);
|
||||
if (v.error) return res.status(400).json({ error: v.error });
|
||||
|
||||
Reference in New Issue
Block a user