# proxy-ws

Rust/Axum-шлюз, стоящий между фронтендом (Next.js) и PocketBase (CMS/БД). Независимый сервис — не зависит от `matrix-engine` или самого фронта, общается с ними только по HTTP.

У сервиса две обязанности:

1. **AI-модерация гостевой книги** — принимает комментарии с фронта, прогоняет никнейм и текст через ONNX-классификатор токсичности и, если всё чисто, форвардит запись в PocketBase.
2. **Backend для спидтеста** — отдаёт несжимаемый блоб на скачивание, принимает и считает байты на загрузку, отвечает на ping — без сохранения каких-либо данных.

## Эндпоинты

| Метод | Путь                     | Назначение                                                                 |
|-------|--------------------------|------------------------------------------------------------------------------|
| POST  | `/api/comments`          | Приём сообщения гостевой книги, ИИ-модерация, форвардинг в PocketBase        |
| GET   | `/api/speedtest/ping`    | Возвращает `pong` — для измерения RTT                                        |
| GET   | `/api/speedtest/download`| Отдаёт статический 25 МБ несжимаемый блоб — для измерения скорости скачивания|
| POST  | `/api/speedtest/upload`  | Принимает тело запроса, считает байты, ничего не сохраняет                   |

Лимит тела запроса — 100 МБ (`DefaultBodyLimit`), нужен именно из-за speedtest upload.

## Как работает модерация

Пайплайн для каждого проверяемого текста (`classifier.rs`):

```
текст -> токенизация (tokenizers) -> ONNX-модель (tract, чистый Rust, без Python) -> логиты -> сигмоида -> порог 0.85
```

Модель — бинарный BERT-подобный классификатор токсичности. Инференс выполняется через `tract-onnx`, поэтому сервису не нужен ни Python, ни какой-либо ML-рантайм на уровне ОС — только сам `.onnx`-файл модели и токенизатор, лежащие в `models/`.

Особенности пайплайна:

- **Никнейм проверяется всегда**, без исключений.
- **Текст сообщения проходит гвард-рейлы** перед инференсом: сообщения из whitelist безобидных слов (`vpn`, `service`, `спасибо`, `тест` и т.п.), короткие (< 4 символов) или не содержащие ни одной буквы — на модель вообще не отправляются и автоматически считаются нетоксичными. Это защищает от false positives на коротких безобидных фразах вроде "спасибо, работает" и экономит инференс.
- **Инференс — блокирующая операция**, поэтому выполняется в `tokio::task::spawn_blocking`, чтобы не задерживать поток из пула async-раннтайма.
- **Fail-open при ошибках модели**: любая ошибка на любом шаге (токенизация, прогон модели, неожиданная форма выходного тензора) означает, что текст считается нетоксичным и пропускается дальше. Это осознанный компромисс в пользу доступности сервиса — сбой модели не должен ронять или блокировать гостевую книгу целиком.

Если хотя бы один из текстов (никнейм или прошедшее гвард-рейлы сообщение) признан токсичным — сервис отвечает `406 Not Acceptable` и ничего не форвардит в PocketBase.

## Переменные окружения

| Переменная       | Назначение                                            | Значение по умолчанию          |
|------------------|--------------------------------------------------------|---------------------------------|
| `PB_INTERNAL_URL`| Внутренний адрес PocketBase, куда форвардятся комментарии | `http://netrunner-cms:8090`    |

## Как собрать и запустить локально

### Через Cargo

Нужны файлы модели в `models/` (`model.onnx`, `tokenizer.json` и сопутствующие конфиги — уже лежат в репозитории).

```bash
cd proxy-ws
cargo run
```

Сервис поднимется на `0.0.0.0:3001`.

### Через Docker

Dockerfile лежит внутри `proxy-ws/`, но собирать образ нужно из корня репозитория (контекст сборки — вся папка `proxy-ws`):

```bash
docker build -t proxy-ws ./proxy-ws
docker run -p 3001:3001 -e PB_INTERNAL_URL=http://netrunner-cms:8090 proxy-ws
```

Сборка двухстадийная (Alpine): сначала кэшируются зависимости отдельной компиляцией фейкового `main.rs`, затем собирается реальный код — итоговый образ содержит только бинарник и `models/`, без Rust-тулчейна.
