206 lines
13 KiB
JavaScript
206 lines
13 KiB
JavaScript
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 };
|
||
}
|