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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user