This commit is contained in:
2026-07-02 23:13:24 +07:00
parent 31f354d9ca
commit c04194bb85
36 changed files with 1005 additions and 41 deletions
+31 -1
View File
@@ -1,3 +1,8 @@
//! Обёртка над предобученной ONNX-моделью (BERT-подобный классификатор
//! токсичности текста), выполняющая инференс без Python-рантайма — только
//! `tract-onnx` (чистый Rust ONNX-runtime) и `tokenizers` для препроцессинга.
//! Это позволяет держать сервис лёгким и без внешних зависимостей уровня ОС.
use anyhow::{anyhow, Result};
use tokenizers::Tokenizer;
use tract_onnx::prelude::*;
@@ -8,6 +13,12 @@ pub struct ToxicityClassifier {
}
impl ToxicityClassifier {
/// Загружает токенизатор и ONNX-модель с диска и компилирует граф модели
/// в исполняемый план (`into_optimized().into_runnable()`).
///
/// Вызывается один раз при старте сервиса (см. `main.rs`) — сама загрузка
/// и оптимизация графа занимают заметное время, поэтому переиспользуем
/// результат на все последующие запросы через `AppState`.
pub fn new(model_path: &str, tokenizer_path: &str) -> Result<Self> {
let tokenizer = Tokenizer::from_file(tokenizer_path)
.map_err(|e| anyhow!("Tokenizer load error: {}", e))?;
@@ -20,6 +31,19 @@ impl ToxicityClassifier {
Ok(Self { model, tokenizer })
}
/// Определяет, является ли текст токсичным.
///
/// Пайплайн: токенизация -> три входных тензора (`ids`/`attention_mask`/
/// `token_type_ids`) формы `[1, seq_len]` -> прогон через модель ->
/// логиты -> сигмоида на логите токсичного класса -> порог 0.85.
///
/// Стратегия обработки ошибок — намеренно "fail-open": любая ошибка на
/// любом шаге (токенизация, инференс, неожиданная форма выходного
/// тензора) приводит к `return false`, т.е. текст считается нетоксичным
/// и пропускается дальше. Это осознанный выбор в пользу доступности
/// сервиса: лучше изредка пропустить нежелательный текст при сбое модели,
/// чем уронить или заблокировать всю гостевую книгу из-за проблем с
/// инференсом (повреждённые данные, несовместимая версия рантайма и т.п.).
pub fn is_toxic(&self, text: &str) -> bool {
let encoding = match self.tokenizer.encode(text, true) {
Ok(e) => e,
@@ -61,6 +85,9 @@ impl ToxicityClassifier {
};
// --- БРОНЕБОЙНОЕ РЕШЕНИЕ (Никаких паник ndarray) ---
// to_array_view может вернуть Err, если фактическая форма/тип выходного
// тензора отличается от ожидаемого (например, из-за другой версии
// модели) — в этом случае, как и везде здесь, просто fail-open.
let view = match outputs[0].to_array_view::<f32>() {
Ok(v) => v,
Err(_) => return false,
@@ -69,7 +96,10 @@ impl ToxicityClassifier {
// Превращаем любую матрицу (хоть 2D, хоть 3D) в плоский список чисел
let logits: Vec<f32> = view.iter().copied().collect();
// Теперь logits это просто одномерный массив, например [-2.5, 4.8]
// Теперь logits это просто одномерный массив, например [-2.5, 4.8].
// Модель бинарная: индекс 0 — логит класса "нетоксично", индекс 1 —
// логит класса "токсично". Если модель почему-то вернула один логит
// (не two-class head), используем его напрямую как запасной вариант.
let toxic_logit = if logits.len() >= 2 {
logits[1] // Вероятность мата
} else if logits.len() == 1 {
+38 -1
View File
@@ -20,6 +20,15 @@ struct CommentPayload {
}
// Восстановленный генератор нагрузки для спидтеста (25 MB)
//
// Байты генерируются линейным конгруэнтным генератором (LCG), а не просто
// заполняются нулями: нулевой (или вообще любой сильно сжимаемый) блоб при
// передаче через gzip/brotli в HTTP-стеке "похудеет" в десятки раз, и клиент
// измерит скорость сжатия, а не скорость сети. LCG даёт дешёвый (без крипто-
// рандома и внешних зависимостей) псевдослучайный поток байт, который не
// сжимается, поэтому размер тела на проводе честно равен 25 МБ. Генерируется
// один раз при первом обращении (LazyLock) и переиспользуется для всех
// последующих запросов.
static RANDOM_PAYLOAD: LazyLock<Bytes> = LazyLock::new(|| {
let mut data = vec![0u8; 25 * 1024 * 1024];
let mut state: u32 = 123456789;
@@ -30,6 +39,11 @@ static RANDOM_PAYLOAD: LazyLock<Bytes> = LazyLock::new(|| {
Bytes::from(data)
});
/// Общее состояние Axum-приложения, разделяемое между всеми запросами через `Arc`.
///
/// Хранит уже загруженный и скомпилированный классификатор токсичности —
/// модель грузится один раз при старте (см. `main`), а не на каждый запрос,
/// т.к. загрузка ONNX-графа и токенизатора — дорогая операция.
struct AppState {
ai_classifier: ToxicityClassifier,
}
@@ -57,6 +71,13 @@ async fn main() {
axum::serve(listener, app).await.unwrap();
}
/// Принимает запись гостевой книги с фронта, модерирует её ИИ-классификатором
/// токсичности и, если всё чисто, форвардит как есть в PocketBase.
///
/// Никнейм проверяется моделью всегда и без исключений (это то, что видно
/// рядом с каждым сообщением, и с него чаще всего пытаются материться).
/// Текст сообщения дополнительно проходит через гвард-рейлы (см. ниже) перед
/// тем, как попасть на инференс.
async fn create_comment(
State(state): State<Arc<AppState>>,
Json(payload): Json<CommentPayload>,
@@ -67,6 +88,17 @@ async fn create_comment(
println!("📩 [INCOMING] '{}': '{}'", nickname, message);
// --- ГВАРД-РЕЙЛЫ (Только для текста сообщения) ---
// Модель — небольшой BERT-классификатор, обученный на относительно длинных
// фразах, и на коротких/безобидных сообщениях склонна давать false positive
// (например, "ок", "супер", "спасибо, работает" иногда цепляют высокий
// скор токсичности просто из-за нехватки контекста). Чтобы не отклонять
// легитимные отзывы, часть сообщений вообще не отправляется на инференс:
// - whitelist явно безобидных слов (в т.ч. частые в отзывах про VPN);
// - совсем короткие сообщения (< 4 символов) — на них модель нестабильна;
// - сообщения без единой буквы (эмодзи, знаки препинания, цифры) — там
// токсичности в текстовом смысле в принципе быть не может.
// Это осознанный компромисс: немного снижает строгость модерации ради
// пользовательского опыта и экономии инференса на заведомо безопасном тексте.
let whitelist = [
"vpn",
"service",
@@ -87,7 +119,12 @@ async fn create_comment(
let msg_safe_by_guard = msg_whitelisted || message.len() < 4 || !msg_has_alphabetic;
// Запускаем проверку
// Запускаем проверку.
// tract-onnx выполняет инференс синхронно и CPU-bound (никаких await внутри),
// поэтому вызов напрямую в async-функции застолбил бы поток из пула Tokio
// на всё время работы модели, не давая ему обслуживать другие запросы.
// spawn_blocking переносит эту работу в отдельный пул потоков, специально
// предназначенный для блокирующих операций, освобождая async-раннтайм.
let is_toxic = tokio::task::spawn_blocking(move || {
// 1. ВСЕГДА проверяем никнейм
let is_nick_toxic = state.ai_classifier.is_toxic(&nickname);