Add feedback system functionality

- Implement feedback management system in admin interface
- Add feedback processing and AI-based features
- Update user interface with new feedback components
- Modify server-side API endpoints for feedback handling
- Enhance worker.js for background feedback processing
- Update deployment configuration for feedback components

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
dev
2026-10-06 13:10:14 +03:00
co-authored by openhands
parent a3169648f3
commit 98f24faaaf
13 changed files with 1717 additions and 31 deletions
+299 -1
View File
@@ -1051,4 +1051,302 @@ async function loop() {
return { start, stop, notify, getInfo };
}
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, lessonTopicPosition };
function createFeedbackWriter(opts) {
const pool = opts.pool;
const getSetting = opts.getSetting;
const logAudit = opts.logAudit;
const aiUrl = (opts.aiUrl || process.env.AI_URL || 'http://text-corrector:8080');
const defaultPrompt = opts.defaultPrompt || '';
const bus = opts.bus;
const onDone = opts.onDone || (() => {});
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const TIMEOUT_MS = opts.timeoutMs || parseInt(process.env.FEEDBACK_AI_TIMEOUT_MS || '120000', 10) || 120000;
const MAX_ATTEMPTS = 3;
const IDLE_MIN_MS = 500;
const IDLE_MAX_MS = 30000;
const MIN_CHARS = 1;
const MAX_INPUT_CHARS = 6000;
const TEXT_MAX = 5000;
let running = false;
let timer = null;
let stopped = false;
let idleMs = IDLE_MIN_MS;
let inFlight = false;
function notify() {
if (!running || inFlight) return;
clearTimeout(timer);
timer = setTimeout(tick, 50);
}
if (bus && typeof bus.subscribe === 'function') {
try { bus.subscribe(notify); } catch (e) {}
}
async function getSettingValue(key, def) {
try {
const v = await getSetting(key, def);
return v;
} catch (e) {
return def;
}
}
async function resolveActiveProfile(profileId) {
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 p = profiles.find(x => String(x.id) === String(profileId));
if (p) return { type: 'profile', profile: p };
}
if (String(active) !== 'native') {
const p = profiles.find(x => String(x.id) === String(active));
if (p) return { type: 'profile', profile: p };
}
return { type: 'native', model: AI_MODEL, baseUrl: aiUrl };
} catch (e) {
return { type: 'native', model: AI_MODEL, baseUrl: aiUrl };
}
}
function normalizeOpenAiBase(baseUrl) {
const b = (baseUrl || '').replace(/\/$/, '');
return b.includes('/v1') ? b : `${b}/v1`;
}
async function callModel(systemPrompt, userText, profileId) {
const text = String(userText || '');
if (text.length > MAX_INPUT_CHARS) {
return text.slice(0, TEXT_MAX);
}
const resolved = await resolveActiveProfile(profileId);
const controller = new AbortController();
const timerAbort = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
let url, headers, body;
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))
};
} 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))
};
}
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() || '';
} finally {
clearTimeout(timerAbort);
}
}
function normalizeOutput(s) {
let out = String(s || '').trim();
if (out.startsWith('```') && out.endsWith('```')) {
out = out.slice(3, -3).trim();
}
out = out.replace(/^(Итоговый текст:|Отзыв:)\s*/i, '');
out = out.replace(/^"|"$/g, '');
out = out.replace(/\n{3,}/g, '\n\n');
return out;
}
async function isEnabled() {
try {
const v = await getSettingValue('feedback_ai_enabled', 'true');
return String(v) !== 'false';
} catch (e) {
return true;
}
}
async function claimNext() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const q = `SELECT f.*, g.name AS group_name, g.branch_id AS group_branch_id
FROM feedbacks f
LEFT JOIN groups g ON g.id = f.group_id
WHERE f.ai_status = 'pending' AND f.deleted_at IS NULL
ORDER BY f.id ASC
FOR UPDATE OF f SKIP LOCKED
LIMIT 1`;
const r = await client.query(q);
if (r.rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
const row = r.rows[0];
await client.query(`UPDATE feedbacks SET ai_status='processing', updated_at=now() WHERE id=$1`, [row.id]);
await client.query('COMMIT');
row.branch_id = row.branch_id ?? row.group_branch_id ?? null;
return row;
} catch (e) {
try { await client.query('ROLLBACK'); } catch (e2) {}
throw e;
} finally {
client.release();
}
}
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
}
let context = '';
context += `Группа: ${row.group_name || ''}\n`;
context += `Дата занятия: ${row.feedback_date || ''}\n`;
context += `Резидент: ${row.resident || ''}\n`;
context += `Тема занятия: ${row.topic || ''}\n`;
context += `Прошлый отзыв: ${row.past_review || 'нет'}\n`;
let shorts = [];
try {
const ids = Array.isArray(row.short_message_ids) ? row.short_message_ids : [];
if (ids.length) {
const sr = await pool.query(`SELECT resident, lesson_date, topic, message FROM short_messages WHERE id = ANY($1::int[]) AND deleted_at IS NULL ORDER BY id`, [ids]);
shorts = sr.rows;
}
} catch (e) {}
if (shorts.length) {
context += 'Короткие сообщения (выбраны):\n';
for (const s of shorts) {
context += `- ${s.lesson_date || ''} – ${s.topic || '-'} – ${s.message || ''}\n`;
}
}
const systemPrompt = defaultPrompt + (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: 'Пустой ответ от модели' };
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: 'done', text: norm, aiText: norm };
}
async function finish(row, res) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const sets = [];
const params = [];
let i = 1;
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`); }
if (res.status === 'done' && res.text) { sets.push(`text=$${i++}`); params.push(res.text); }
sets.push(`updated_at=now()`);
params.push(row.id);
await client.query(`UPDATE feedbacks SET ${sets.join(', ')} WHERE id=$${i}`, params);
await client.query('COMMIT');
} catch (e) {
try { await client.query('ROLLBACK'); } catch (e2) {}
throw e;
} finally {
client.release();
}
try { onDone({ row, status: res.status, text: res.text || row.text, aiText: res.aiText, error: res.error }); } catch (e) {}
}
async function tick() {
if (stopped || !running || inFlight) return;
const enabled = await isEnabled();
if (!enabled) {
idleMs = Math.min(idleMs * 2, IDLE_MAX_MS);
timer = setTimeout(tick, idleMs);
return;
}
inFlight = true;
try {
let attempts = 0;
while (attempts < MAX_ATTEMPTS) {
attempts++;
const row = await claimNext();
if (!row) break;
let res;
try {
res = await processOne(row);
} catch (e) {
res = { status: 'error', error: e.message };
}
if (res.status === 'error' && attempts < MAX_ATTEMPTS) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`UPDATE feedbacks SET ai_status='pending', updated_at=now(), ai_error=$2 WHERE id=$1`, [row.id, res.error || 'error']);
await client.query('COMMIT');
} catch (e2) {
try { await client.query('ROLLBACK'); } catch (e3) {}
} finally {
client.release();
}
idleMs = IDLE_MIN_MS;
continue;
}
await finish(row, res);
idleMs = IDLE_MIN_MS;
}
} catch (e) {
idleMs = Math.min(idleMs * 2, IDLE_MAX_MS);
} finally {
inFlight = false;
timer = setTimeout(tick, idleMs);
}
}
async function resetStale() {
try {
await pool.query(`UPDATE feedbacks SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL WHERE ai_status='processing'`);
} catch (e) {}
}
function start() {
if (running) return;
running = true;
stopped = false;
idleMs = IDLE_MIN_MS;
resetStale().finally(() => { tick(); });
}
function stop() {
running = false;
stopped = true;
if (timer) { clearTimeout(timer); timer = null; }
}
function getInfo() {
return { running, inFlight, idleMs };
}
return { start, stop, notify, getInfo };
}
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker, createFeedbackWriter, lessonTopicPosition };