docs
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user