Files
test_ai_assigment/classify.mjs
T

206 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}