Add EGE support prototype v0.1.0

This commit is contained in:
alexglados1 committed 2026-11-13 14:14:31 +03:00
1 parent 9944a41cca
commit 24cae23262
19 files changed
+2581 -2

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
import { readFileSync } from 'node:fs';
import { isAdvertising } from './chat-policy.mjs';
export { moderateChatMessage } from './chat-policy.mjs';
export const categories = { urgent: 'Срочно', normal: 'Не срочно', spam: 'Спам' };
export const topics = {
learning: 'Учёба и вебинары',
file: 'Открытие файла',
access: 'Права доступа',
bug: 'Сообщение о баге',
other: 'Другое',
spam: 'Спам',
};
export const spamRecommendation = 'Проверьте обращение. Если это спам, закройте его без ответа.';
const systemPrompt = readFileSync(new URL('./triage-prompt.md', import.meta.url), 'utf8');
const resultSchema = {
type: 'object',
additionalProperties: false,
required: ['category', 'topic', 'studentImpact', 'needsReview', 'reason', 'draft'],
properties: {
category: { type: 'string', enum: Object.keys(categories) },
topic: { type: 'string', enum: Object.keys(topics) },
studentImpact: { type: 'boolean' },
needsReview: { type: 'boolean' },
reason: { type: 'string' },
draft: { type: 'string' },
},
};
export function maskContactDetails(text) {
return text
.replace(/[\p{L}\d._%+-]+@[\p{L}\d.-]+\.[\p{L}]{2,}/gu, '[email скрыт]')
.replace(/(?<!\d)(?:\+7|8)[\s().-]*\d{3}[\s().-]*\d{3}[\s.-]*\d{2}[\s.-]*\d{2}(?!\d)/gu, '[телефон скрыт]')
.replace(/(?:https?:\/\/)?(?:www\.)?[\p{L}\d.-]+\.[\p{L}]{2,}\/(?:id\d+|@[\p{L}\d_.-]+|(?:users?|profiles?)\/[\p{L}\d_.-]+)/giu, '[ссылка на профиль скрыта]')
.replace(/\bsk-[a-z0-9_-]{12,}\b/giu, '[ключ скрыт]')
.replace(/((?:пароль|код из смс|api[ -]?(?:key|ключ)|ключ api|токен)\s*[:=]\s*)\S+/giu, '$1[скрыто]');
}
function demoTriage(actor, text) {
if (isAdvertising(text)) {
return {
category: 'spam', topic: 'spam', studentImpact: false, needsReview: false,
reason: 'Текст похож на явное рекламное предложение.',
draft: spamRecommendation,
};
}
const failure = /(?:не (?:мог[уа]|могут|уда[её]тся|работа|открыва|груз|загружа|запуска|сохраня|видн|получа)|ошибк|сбо[йия]|пада[ею]|недоступ|завис|блокиру)/iu.test(text);
const learning = /вебинар|эфир|трансляци|домашн[а-яё]+ задани|домашк|каталог|урок|плеер/iu.test(text);
const live = /прямо сейчас|уже ид[её]т|сейчас ид[её]т|ид[её]т сейчас|сейчас (?:полностью |временно )?(?:не |недоступ|заблок|упал|пада)|уже начал|во время (?:эфира|вебинара)/iu.test(text);
const deadline = /дедлайн.{0,28}(?:сегодня|через \d{1,2} минут|через (?:полчаса|час)(?![а-яё])|скоро)|(?:сегодня|через \d{1,2} минут|скоро).{0,28}дедлайн|до конца (?:сегодняшнего )?дня/isu.test(text);
const mass = /всей групп|вся групп|у всех (?:ученик|пользовател)|массовый сбой|много ученик/iu.test(text);
const reportsStudentIssue = /(?:учени(?:к[аи]?|ц[аы])|пользовател[ьи]).{0,24}(?:не (?:мог|вид|откры|получ)|получа[ею]т ошибк)|(?:у (?:всех )?(?:ученик[а-яё]*|пользовател[а-яё]*)|(?:всей групп[еы]|вся группа) учеников).{0,30}(?:не |ошибк|сбой|завис|недоступ)|затрагива[ею]т учеников|меша[ею]т ученикам/isu.test(text);
const noStudentImpact = /ученик[а-яё]* (?:не затронут|ошибок не)|учеников (?:это )?не затрагива|учеников (?:это )?не затронуло|у учеников (?:нет ошиб|ошибок нет)|сейчас (?:у учеников )?вс[её] работает/iu.test(text);
const studentImpact = actor === 'staff' && failure && reportsStudentIssue && !noStudentImpact;
const urgentInternal = actor === 'staff' && live && /критич|полностью недоступ|вс(?:я|ей) команд|все сотрудники|заблокирова[а-яё]*.{0,20}(?:выдача прав|работа|проверка)/isu.test(text);
const urgentLearning = (actor === 'student' || studentImpact) && (mass || deadline || (live && learning));
const urgent = failure && (urgentLearning || urgentInternal);
const category = urgent ? 'urgent' : 'normal';
const fileFormat = /\b(?:pdf|docx?|odt|rtf|txt|pptx?|xlsx?)\b/iu.exec(text)?.[0].toLowerCase();
const file = Boolean(fileFormat) || /(?:файл|документ)[а-яё]*/iu.test(text);
const access = /(?:^|[^а-яё])(?:права|рол[ьи]|разрешени|выда[тьй])|доступ.{0,20}(?:сотрудник|куратор)|нов[а-яё]+ (?:сотрудник|коллег|куратор)/isu.test(text);
const bug = /\b(?:bug|[45]\d\d)\b|(?:баг|админк)[а-яё]*/iu.test(text);
let topic = 'other';
let draft = 'Здравствуйте! Уточните, пожалуйста, на какой странице возникла проблема и что происходит при повторе действия.';
if (access && !(bug && failure)) {
topic = 'access';
draft = actor === 'staff'
? 'Уточните, пожалуйста, роль нового сотрудника и нужные разделы сайта. Внутренний идентификатор передайте в рабочем канале. После этого проверим заявку на доступ.'
: 'Напишите, пожалуйста, к какому разделу не получается получить доступ и что показывает сайт.';
} else if (file) {
topic = 'file';
if (fileFormat === 'pdf') {
draft = 'Здравствуйте! Попробуйте скачать PDF и открыть его во встроенном просмотрщике устройства или браузера. Если не получится, напишите, какая ошибка появляется.';
} else if (fileFormat === 'txt') {
draft = 'Здравствуйте! Попробуйте открыть скачанный TXT в текстовом редакторе, например в Блокноте на Windows. Если не получится, напишите, какое у вас устройство и какая ошибка появляется.';
} else if (fileFormat) {
draft = 'Здравствуйте! Попробуйте скачать файл и открыть его в офисном приложении, поддерживающем этот формат. Если файл снова не откроется, пришлите текст ошибки.';
} else {
draft = 'Здравствуйте! Уточните расширение файла (например, PDF или DOCX), ваше устройство и что происходит при открытии.';
}
} else if (bug) {
topic = 'bug';
draft = 'Спасибо за сообщение. Укажите ссылку или название раздела, если их нет в обращении. По описанию попробуем воспроизвести ошибку и передадим подтверждённый баг разработчикам.';
} else if (learning) {
topic = 'learning';
draft = 'Здравствуйте! Поможем разобраться. Если их ещё нет в сообщении, уточните название материала и текст ошибки. Проверим, где возникает сбой.';
}
let reason = 'В тексте нет признаков срочной блокировки работы.';
if (urgent && urgentInternal && !studentImpact) {
reason = 'Описан текущий сбой, который блокирует критичную внутреннюю задачу.';
} else if (urgent) {
reason = 'Описан сбой во время занятия, перед дедлайном или у группы пользователей.';
} else if (topic === 'other') {
reason = 'В сообщении недостаточно сведений для уверенной оценки.';
}
return {
category, topic, studentImpact, reason, draft,
needsReview: access || topic === 'other' || (file && !fileFormat),
};
}
function completionUrl(base) {
const url = new URL(base);
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
throw new Error('Для внешнего API требуется HTTPS. HTTP подходит для локального теста.');
}
if (url.username || url.password || url.search || url.hash) {
throw new Error('Базовый адрес API должен содержать только сервер и путь.');
}
url.pathname = `${url.pathname.replace(/\/+$/, '')}/chat/completions`;
return url;
}
export function configureTriage(env) {
const mode = env.SUPPORT_MODE || 'demo';
if (mode === 'demo') {
return { mode, provider: null, model: null, classify: demoTriage };
}
if (mode !== 'llm') throw new Error('SUPPORT_MODE принимает demo или llm.');
const provider = env.SUPPORT_PROVIDER || 'openai';
if (!['openai', 'yandex'].includes(provider)) {
throw new Error('SUPPORT_PROVIDER принимает openai или yandex.');
}
const yandex = provider === 'yandex';
const apiKey = yandex ? env.YANDEX_API_KEY : env.OPENAI_API_KEY;
const folder = env.YANDEX_FOLDER_ID;
const model = yandex ? env.YANDEX_MODEL || `gpt://${folder}/qwen3.6-35b-a3b` : env.OPENAI_MODEL;
if (!apiKey || (yandex ? !folder : !model)) {
const variables = yandex ? 'YANDEX_API_KEY и YANDEX_FOLDER_ID' : 'OPENAI_API_KEY и OPENAI_MODEL';
throw new Error(`Для режима LLM задайте ${variables}.`);
}
const endpoint = completionUrl(yandex
? env.YANDEX_BASE_URL || 'https://ai.api.cloud.yandex.net/v1'
: env.OPENAI_BASE_URL || 'https://api.openai.com/v1');
const headers = { 'Content-Type': 'application/json' };
if (yandex) {
headers.Authorization = `Api-Key ${apiKey}`;
headers['OpenAI-Project'] = folder;
} else {
headers.Authorization = `Bearer ${apiKey}`;
}
const format = { name: 'support_triage', schema: resultSchema };
if (!yandex) format.strict = true;
async function classify(actor, text, latestMessage) {
let response;
try {
response = await fetch(endpoint, {
method: 'POST', headers, redirect: 'error', signal: AbortSignal.timeout(20_000),
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: JSON.stringify({ actor, text, latestMessage }) },
],
response_format: { type: 'json_schema', json_schema: format },
...(yandex ? { max_tokens: 550 } : { max_completion_tokens: 550 }),
...(yandex && /\/qwen[^/]*(?:\/|$)/iu.test(model) ? { reasoning_effort: 'none' } : {}),
}),
});
} catch {
throw new Error('ИИ-сервис не ответил. Проверьте соединение и повторите обработку.');
}
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(`ИИ-сервис вернул ошибку HTTP ${response.status}. Проверьте настройки API и повторите обработку.`);
}
let result;
try {
const responseBody = await response.json();
const content = responseBody.choices?.[0]?.message?.content;
result = JSON.parse(content);
} catch {
throw new Error('ИИ-сервис вернул ответ, который не удалось прочитать. Повторите обработку.');
}
if (!result || typeof result.category !== 'string' || !Object.hasOwn(categories, result.category)
|| typeof result.topic !== 'string' || !Object.hasOwn(topics, result.topic)
|| typeof result.studentImpact !== 'boolean' || typeof result.needsReview !== 'boolean'
|| typeof result.reason !== 'string' || !result.reason.trim() || result.reason.length > 450
|| typeof result.draft !== 'string' || !result.draft.trim() || result.draft.length > 2500) {
throw new Error('Ответ ИИ не прошёл проверку формата. Повторите обработку.');
}
return {
category: result.category,
topic: result.category === 'spam' ? 'spam' : result.topic === 'spam' ? 'other' : result.topic,
studentImpact: actor === 'staff' && result.category !== 'spam' && result.studentImpact,
needsReview: result.topic === 'access' || result.needsReview,
reason: maskContactDetails(result.reason.trim()),
draft: result.category === 'spam' ? spamRecommendation : maskContactDetails(result.draft.trim()),
};
}
return { mode, provider, model, classify };
}