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