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:
dev
2026-10-07 16:30:27 +03:00
parent a73b1b3f8c
commit a9fb7aaf77
13 changed files with 522 additions and 85 deletions
+104 -34
View File
@@ -1068,6 +1068,7 @@ function createFeedbackWriter(opts) {
const MIN_CHARS = 1;
const MAX_INPUT_CHARS = 6000;
const TEXT_MAX = 5000;
const MAX_TOKENS_CAP = 8192;
let running = false;
let timer = null;
@@ -1094,13 +1095,29 @@ function createFeedbackWriter(opts) {
}
}
async function resolveActiveProfile(profileId) {
async function getPersonalModels(authorId) {
if (!authorId) return [];
try {
const r = await pool.query(`SELECT value FROM user_settings WHERE user_id = $1 AND key = 'feedback_models'`, [authorId]);
const raw = r.rows[0] && r.rows[0].value;
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch (e) {
return [];
}
}
async function resolveActiveProfile(profileId, authorId) {
try {
const active = await getSettingValue('ai_active_profile', 'native');
const profilesRaw = await getSettingValue('ai_profiles', '[]');
let profiles = [];
try { profiles = JSON.parse(profilesRaw); } catch (e) {}
if (profileId) {
const mine = await getPersonalModels(authorId);
const own = mine.find(x => String(x.id) === String(profileId));
if (own) return { type: 'profile', profile: own };
const p = profiles.find(x => String(x.id) === String(profileId));
if (p) return { type: 'profile', profile: p };
}
@@ -1119,49 +1136,57 @@ function createFeedbackWriter(opts) {
return b.includes('/v1') ? b : `${b}/v1`;
}
async function callModel(systemPrompt, userText, profileId) {
async function callModel(systemPrompt, userText, profileId, authorId) {
const text = String(userText || '');
const resolved = await resolveActiveProfile(profileId, authorId);
const modelName = resolved.type === 'profile'
? (resolved.profile.model || AI_MODEL)
: (resolved.model || AI_MODEL);
if (text.length > MAX_INPUT_CHARS) {
return text.slice(0, TEXT_MAX);
return { content: text.slice(0, TEXT_MAX), model: modelName };
}
const resolved = await resolveActiveProfile(profileId);
const controller = new AbortController();
const timerAbort = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
let url, headers, body;
let url, headers, model, messages, maxTokens;
if (resolved.type === 'profile') {
const p = resolved.profile;
const base = normalizeOpenAiBase(p.base_url || aiUrl);
url = `${base}/chat/completions`;
headers = { 'Content-Type': 'application/json' };
if (p.api_key) headers.Authorization = `Bearer ${p.api_key}`;
body = {
model: p.model || AI_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
],
temperature: 0.1,
max_tokens: p.max_tokens || Math.min(2048, Math.max(256, text.length * 2))
};
model = p.model || AI_MODEL;
maxTokens = parseInt(p.max_tokens, 10) || Math.min(8192, Math.max(4096, text.length * 2 + 512));
} else {
const base = normalizeOpenAiBase(resolved.baseUrl || aiUrl);
url = `${base}/chat/completions`;
headers = { 'Content-Type': 'application/json' };
body = {
model: resolved.model || AI_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
],
temperature: 0.1,
max_tokens: Math.min(2048, Math.max(256, text.length * 2))
};
model = resolved.model || AI_MODEL;
maxTokens = Math.min(8192, Math.max(4096, text.length * 2 + 512));
}
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal });
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
return data.choices?.[0]?.message?.content?.trim() || '';
messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
];
for (let i = 0; i < 2; i++) {
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ model, messages, temperature: 0.1, max_tokens: maxTokens }),
signal: controller.signal
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
const choice = data.choices && data.choices[0];
const content = (choice && choice.message && choice.message.content || '').trim();
const finishReason = (choice && choice.finish_reason) || '';
if (!content && finishReason === 'length' && maxTokens < MAX_TOKENS_CAP) {
maxTokens = Math.min(MAX_TOKENS_CAP, maxTokens * 4);
continue;
}
return { content, finishReason, model: modelName };
}
return { content: '', finishReason: 'length', model: modelName };
} finally {
clearTimeout(timerAbort);
}
@@ -1271,18 +1296,59 @@ function createFeedbackWriter(opts) {
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: 'Пустой ответ от модели' };
const userMsg = row.text && row.text.trim() ? row.text : (context || 'Сформируй отзыв по контексту');
const t0 = Date.now();
let call;
try {
call = await callModel(systemPrompt, userMsg, row.ai_profile || null, row.author_id || null);
} catch (e) {
return { status: 'error', error: e.message || 'error', prompt: systemPrompt, input: userMsg, durationMs: Date.now() - t0 };
}
const durationMs = Date.now() - t0;
const meta = { prompt: systemPrompt, input: userMsg, output: call.content, model: call.model, durationMs };
let norm = normalizeOutput(call.content);
if (!norm) {
const reason = call.finishReason ? ` (finish_reason=${call.finishReason})` : '';
return { status: 'error', error: `Пустой ответ от модели${reason}`, ...meta };
}
if (norm.length > TEXT_MAX) norm = norm.slice(0, TEXT_MAX);
const oldText = (row.text || '').trim();
if (oldText && norm.trim() === oldText) {
return { status: 'skipped', text: oldText };
return { status: 'skipped', text: oldText, ...meta };
}
return { status: 'done', text: norm, aiText: norm };
return { status: 'done', text: norm, aiText: norm, ...meta };
}
async function finish(row, res) {
function capStr(v, max) {
if (v == null) return null;
const s = String(v);
return s.length > max ? s.slice(0, max) : s;
}
function auditRun(row, res, attempt) {
const payload = {
id: row.id,
status: res.status,
attempt: attempt || 1,
model: res.model || null,
profile: row.ai_profile || 'native',
duration_ms: Number.isFinite(res.durationMs) ? Math.round(res.durationMs) : null,
prompt: capStr(res.prompt, 12000),
input: capStr(res.input, 6000),
error: res.error ? String(res.error).slice(0, 500) : null,
len_before: (row.text || '').length
};
if (res.status === 'error') {
Promise.resolve(logAudit(null, 'feedback.ai.error', payload)).catch(() => {});
return;
}
payload.output = capStr(res.output, 6000);
payload.changed = res.status === 'done';
payload.len_after = res.text ? res.text.length : null;
Promise.resolve(logAudit(null, 'feedback.ai.run', payload)).catch(() => {});
}
async function finish(row, res, attempt) {
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -1292,6 +1358,9 @@ function createFeedbackWriter(opts) {
sets.push(`ai_status=$${i++}`); params.push(res.status);
sets.push(`ai_checked_at=now()`);
if (res.error) { sets.push(`ai_error=$${i++}`); params.push(res.error); } else { sets.push(`ai_error=NULL`); }
sets.push(`ai_prompt=$${i++}`); params.push(res.prompt != null ? res.prompt : null);
sets.push(`ai_model=$${i++}`); params.push(res.model != null ? res.model : null);
sets.push(`ai_duration_ms=$${i++}`); params.push(Number.isFinite(res.durationMs) ? Math.round(res.durationMs) : null);
if (res.status === 'done' && res.text) { sets.push(`text=$${i++}`); params.push(res.text); }
sets.push(`updated_at=now()`);
params.push(row.id);
@@ -1303,6 +1372,7 @@ function createFeedbackWriter(opts) {
} finally {
client.release();
}
auditRun(row, res, attempt);
try { onDone({ row, status: res.status, text: res.text || row.text, aiText: res.aiText, error: res.error }); } catch (e) {}
}
@@ -1341,7 +1411,7 @@ function createFeedbackWriter(opts) {
idleMs = IDLE_MIN_MS;
continue;
}
await finish(row, res);
await finish(row, res, attempts);
idleMs = IDLE_MIN_MS;
}
} catch (e) {