Compare commits

..

10 Commits

Author SHA1 Message Date
SlimusMinus
848f40846a Merge pull request #5 from SlimusMinus/change-image_model
change image model
2026-09-07 01:00:52 +03:00
SlimusMinus
061bad8f67 change image model 2026-09-07 00:59:22 +03:00
SlimusMinus
30c29d8280 Merge pull request #4 from SlimusMinus/del-container
deleted container.py and fix detector
2026-09-07 00:15:07 +03:00
SlimusMinus
3ab97895af deleted container.py and fix detector 2026-09-07 00:13:55 +03:00
SlimusMinus
327394a22a Merge pull request #3 from SlimusMinus/fix-code_v2
fixed code v2
2026-08-06 23:46:29 +03:00
SlimusMinus
4c32c5f4dd fixed code v2 2026-08-06 23:45:38 +03:00
SlimusMinus
fdf2e2a8d5 Merge pull request #2 from SlimusMinus/fix-code
fixed code
2026-08-05 01:45:31 +03:00
SlimusMinus
d33f0eaa78 fixed code 2026-08-05 01:44:29 +03:00
SlimusMinus
72159dbe4a Merge pull request #1 from SlimusMinus/add_image-moderation
added image ai-moderation
2026-07-28 01:10:52 +03:00
SlimusMinus
d376a7417a added image ai-moderation 2026-07-28 01:10:11 +03:00
37 changed files with 8754 additions and 91 deletions

2
.env
View File

@@ -15,3 +15,5 @@ TEXT_MODEL=textdetox/bert-multilingual-toxicity-classifier
MODEL_CACHE_DIR=./models MODEL_CACHE_DIR=./models
DEVICE=auto DEVICE=auto
HF_TOKEN: str = "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"

View File

@@ -0,0 +1,8 @@
# app/config/image_policy.py
FORBIDDEN_IMAGE_LABELS = {
s.lower() for s in {
"a photo containing marijuana",
"a photo containing drugs",
"a photo containing violence",
}
}

View File

@@ -1,6 +1,5 @@
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
APP_NAME: str = "AI Moderation" APP_NAME: str = "AI Moderation"
@@ -12,11 +11,19 @@ class Settings(BaseSettings):
DEBUG: bool = True DEBUG: bool = True
TEXT_TOXIC_THRESHOLD: float = 0.90
# ===== AI ===== # ===== AI =====
TEXT_MODEL: str = "textdetox/bert-multilingual-toxicity-classifier" TEXT_MODEL: str = "textdetox/bert-multilingual-toxicity-classifier"
IMAGE_MODEL: str = "Falconsai/nsfw_image_detection"
NSFW_THRESHOLD: float = 0.85
IMAGE_CLIP_MODEL: str = "openai/clip-vit-base-patch32"
CLIP_THRESHOLD: float = 0.75
WEAPON_MODEL: str = "google/owlv2-base-patch16-ensemble"
WEAPON_THRESHOLD: float = 0.40
WEAPON_MIN_AREA_FRACTION: float = 0.005
MODEL_CACHE_DIR: str = "./models" MODEL_CACHE_DIR: str = "./models"
DEVICE: str = "auto" DEVICE: str = "auto"
@@ -26,4 +33,5 @@ class Settings(BaseSettings):
extra="ignore" extra="ignore"
) )
settings = Settings() settings = Settings()

View File

@@ -0,0 +1,65 @@
from functools import lru_cache
from app.ml.image.clip_classifier import ClipClassifier
from app.ml.image.image_classifier import ImageClassifier
from app.ml.image.weapon_detector import WeaponDetector
from app.ml.text_classifier import TextClassifier
from app.moderation.image.validator import ImageValidator
from app.moderation.profanity.detector import ProfanityDetector
from app.moderation.profanity.dictionary import ProfanityDictionary
from app.moderation.profanity.lemmatizer import Lemmatizer
from app.services.image_moderation_service import ImageModerationService
from app.services.text_moderation_service import TextModerationService
@lru_cache
def get_clip_classifier() -> ClipClassifier:
return ClipClassifier()
@lru_cache
def get_text_classifier() -> TextClassifier:
return TextClassifier()
@lru_cache
def get_profanity_detector() -> ProfanityDetector:
return ProfanityDetector(
dictionary=ProfanityDictionary(
file_path="app/resources/profanity_words.txt"
),
lemmatizer=Lemmatizer()
)
@lru_cache
def get_text_moderation_service() -> TextModerationService:
return TextModerationService(
classifier=get_text_classifier(),
profanity_detector=get_profanity_detector()
)
@lru_cache
def get_image_validator() -> ImageValidator:
return ImageValidator()
@lru_cache
def get_image_classifier() -> ImageClassifier:
return ImageClassifier()
@lru_cache
def get_weapon_detector() -> WeaponDetector:
return WeaponDetector()
@lru_cache
def get_image_moderation_service() -> ImageModerationService:
return ImageModerationService(
classifier=get_image_classifier(),
validator=get_image_validator(),
clip_classifier=get_clip_classifier(),
weapon_detector=get_weapon_detector()
)

View File

@@ -1,4 +0,0 @@
from app.ml.model_manager import model_manager
def get_model_manager():
return model_manager

View File

@@ -0,0 +1,28 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from app.exceptions.invalid_image_exception import InvalidImageException
from app.exceptions.invalid_text_exception import InvalidTextException
from app.models.response.error_response import ErrorResponse
async def invalid_text_exception_handler(
request: Request,
exc: InvalidTextException
):
response = ErrorResponse(
code="INVALID_TEXT",
message=exc.message
)
return JSONResponse(status_code=400, content=response.model_dump())
async def invalid_image_exception_handler(
request: Request,
exc: InvalidImageException
):
response = ErrorResponse(
code="INVALID_IMAGE",
message=exc.message
)
return JSONResponse(status_code=400, content=response.model_dump())

View File

@@ -0,0 +1,5 @@
from app.exceptions.moderation_exception import ModerationException
class InvalidImageException(ModerationException):
pass

View File

@@ -0,0 +1,6 @@
from app.exceptions.moderation_exception import ModerationException
class InvalidTextException(ModerationException):
pass

View File

@@ -0,0 +1,4 @@
class ModerationException(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)

View File

@@ -1,11 +1,14 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app.ml.model_manager import model_manager
from app.config.logging import logger from app.config.logging import logger
from app.ml.model_manager import model_manager
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Loading AI models...") logger.info("Loading AI models...")
model_manager.initialize() model_manager.initialize()
logger.info("AI models loaded.") logger.info("AI models loaded.")

View File

@@ -1,11 +1,24 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.config.settings import settings from app.config.settings import settings
from app.exceptions.handlers import (
invalid_text_exception_handler,
invalid_image_exception_handler
)
from app.exceptions.invalid_text_exception import InvalidTextException
from app.exceptions.invalid_image_exception import InvalidImageException
from app.lifespan import lifespan from app.lifespan import lifespan
from app.api.text_moderation_router import router as moderation_router from app.routers.image_moderation_router import router as image_router
from app.routers.text_moderation_router import router as moderation_router
app = FastAPI( app = FastAPI(
title=settings.APP_NAME, title=settings.APP_NAME,
version=settings.APP_VERSION, version=settings.APP_VERSION,
lifespan=lifespan lifespan=lifespan
) )
app.include_router(moderation_router) app.include_router(moderation_router)
app.include_router(image_router)
app.add_exception_handler(InvalidTextException, invalid_text_exception_handler)
app.add_exception_handler(InvalidImageException, invalid_image_exception_handler)

View File

@@ -0,0 +1,59 @@
import torch
from PIL import Image
from transformers import CLIPProcessor, CLIPModel
from app.config.settings import settings
from app.ml.image.clip_prediction_result import ClipPredictionResult
class ClipClassifier:
LABELS = [
"a normal photo",
"a photo containing marijuana",
"a photo containing drugs",
"a photo containing violence"
]
def __init__(self):
self.device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Loading CLIP on {self.device}")
self.model = CLIPModel.from_pretrained(settings.IMAGE_CLIP_MODEL)
self.processor = CLIPProcessor.from_pretrained(settings.IMAGE_CLIP_MODEL)
self.model.to(self.device)
self.model.eval()
def predict(self, image: Image.Image) -> ClipPredictionResult:
inputs = self.processor(
text=self.LABELS,
images=image,
return_tensors="pt",
padding=True
)
inputs = {k: v.to(self.device) for k, v in inputs.items()}
with torch.no_grad():
outputs = self.model(**inputs)
logits = outputs.logits_per_image
probs = logits.softmax(dim=1)[0]
scores = {}
detected_labels = []
for index, probability in enumerate(probs):
label = self.LABELS[index]
score = float(probability)
scores[label] = score
if label != "a normal photo" and score >= settings.CLIP_THRESHOLD:
detected_labels.append(label)
max_index = torch.argmax(probs).item()
return ClipPredictionResult(
label=self.LABELS[max_index],
score=float(probs[max_index]),
detected_labels=detected_labels,
scores=scores
)

View File

@@ -0,0 +1,12 @@
from dataclasses import dataclass
@dataclass(slots=True)
class ClipPredictionResult:
label: str
score: float
detected_labels: list[str]
scores: dict[str, float]

View File

@@ -0,0 +1,69 @@
import torch
from PIL import Image
from app.ml.image.image_prediction_result import ImagePredictionResult
from app.ml.model_manager import model_manager
class ImageClassifier:
def predict(
self,
image: Image.Image
) -> ImagePredictionResult:
inputs = (
model_manager.image_processor(
image,
return_tensors="pt"
)
)
inputs = {
key: value.to(model_manager.device)
for key, value in inputs.items()
}
with torch.no_grad():
outputs = (
model_manager.image_model(**inputs)
)
probabilities = torch.softmax(
outputs.logits,
dim=1
)
raw_scores = probabilities[0].tolist()
predicted_index = (
torch.argmax(probabilities, dim=1)
.item()
)
labels = (
model_manager.image_model
.config
.id2label
)
label = labels[predicted_index]
score = raw_scores[predicted_index]
print("====================")
print("IMAGE RESULT")
print("Labels:", labels)
print("Label:", label)
print("Score:", score)
print("Scores:", raw_scores)
return ImagePredictionResult(
label=label,
score=score,
approved=False,
raw_scores=raw_scores
)

View File

@@ -0,0 +1,18 @@
from dataclasses import dataclass, field
@dataclass(slots=True)
class ImagePredictionResult:
label: str
score: float
approved: bool
raw_scores: list[float] = field(default_factory=list)
reason: str = ""
detected_labels: list[str] = field(
default_factory=list
)

View File

@@ -1,56 +1,75 @@
from transformers import AutoTokenizer import os
from transformers import AutoModelForSequenceClassification
from app.config.settings import settings
from app.config.logging import logger
import torch import torch
from transformers import AutoImageProcessor
from transformers import AutoModelForImageClassification
from transformers import AutoModelForSequenceClassification
from transformers import AutoTokenizer
from app.config.logging import logger
from app.config.settings import settings
class ModelManager: class ModelManager:
def __init__(self): def __init__(self):
self.device = None self.device = None
self.text_model = None self.text_model = None
self.text_tokenizer = None self.text_tokenizer = None
self.image_model = None
self.image_processor = None
def load_device(self): def _setup_hf_token(self):
if settings.DEVICE == "auto": if settings.HF_TOKEN:
self.device = torch.device( os.environ["HF_TOKEN"] = settings.HF_TOKEN
"cuda" if torch.cuda.is_available() else "cpu" os.environ["HUGGING_FACE_HUB_TOKEN"] = settings.HF_TOKEN
)
else:
self.device = torch.device(settings.DEVICE)
def load_tokenizer(self): def load_device(self):
print("Loading tokenizer...") if settings.DEVICE == "auto":
self.device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
else:
self.device = torch.device(settings.DEVICE)
self.text_tokenizer = AutoTokenizer.from_pretrained( def load_tokenizer(self):
settings.TEXT_MODEL, logger.info("Loading tokenizer...")
cache_dir=settings.MODEL_CACHE_DIR self.text_tokenizer = AutoTokenizer.from_pretrained(
) settings.TEXT_MODEL,
cache_dir=settings.MODEL_CACHE_DIR
)
logger.info("Tokenizer loaded.")
print("Tokenizer loaded.") def load_model(self):
logger.info("Loading model...")
self.text_model = AutoModelForSequenceClassification.from_pretrained(
settings.TEXT_MODEL,
cache_dir=settings.MODEL_CACHE_DIR
)
self.text_model.to(self.device)
self.text_model.eval()
logger.debug("id2label: %s", self.text_model.config.id2label)
logger.info("Text model loaded.")
def load_model(self): def load_image_model(self):
print("Loading model...") logger.info("Loading image model...")
self.image_processor = AutoImageProcessor.from_pretrained(
settings.IMAGE_MODEL,
cache_dir=settings.MODEL_CACHE_DIR
)
self.image_model = AutoModelForImageClassification.from_pretrained(
settings.IMAGE_MODEL,
cache_dir=settings.MODEL_CACHE_DIR
)
self.image_model.to(self.device)
self.image_model.eval()
logger.info("Image model loaded.")
self.text_model = AutoModelForSequenceClassification.from_pretrained( def initialize(self):
settings.TEXT_MODEL, self.load_device()
cache_dir=settings.MODEL_CACHE_DIR logger.info(f"Using device: {self.device}")
) self.load_tokenizer()
self.load_model()
self.text_model.to(self.device) self.load_image_model()
self.text_model.eval()
print(self.text_model.config.id2label)
print("Text model loaded.")
def initialize(self):
self.load_device()
print(f"Using device: {self.device}")
self.load_tokenizer()
self.load_model()
model_manager = ModelManager() model_manager = ModelManager()

View File

@@ -1,8 +1,8 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
@dataclass(slots=True) @dataclass(slots=True)
class PredictionResult: class PredictionResult:
label: str label: str
score: float score: float
@@ -11,4 +11,6 @@ class PredictionResult:
raw_scores: list[float] raw_scores: list[float]
reason: str = field(default="") reason: str = ""
detected_words: list[str] = field(default_factory=list)

View File

@@ -1,9 +1,9 @@
import torch import torch
from app.ml.model_manager import model_manager from app.ml.model_manager import model_manager
from app.config.logging import logger
from app.ml.prediction_result import PredictionResult from app.ml.prediction_result import PredictionResult
class TextClassifier: class TextClassifier:
def predict(self, text: str): def predict(self, text: str):
inputs = model_manager.text_tokenizer( inputs = model_manager.text_tokenizer(

View File

@@ -1,4 +1,9 @@
from pydantic import BaseModel, Field from pydantic import BaseModel
from pydantic import Field
class TextRequest(BaseModel): class TextRequest(BaseModel):
text: str = Field(..., description="The text to be moderated") text: str = Field(
min_length=1,
max_length=5000
)

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class ErrorResponse(BaseModel):
code: str
message: str

View File

@@ -0,0 +1,15 @@
from typing import List
from pydantic import BaseModel
class ImageModerationResponse(BaseModel):
approved: bool
score: float
reason: str
label: str
detected_labels: List[str]

View File

@@ -1,6 +1,9 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ModerationResponse(BaseModel): class ModerationResponse(BaseModel):
approved: bool = Field(..., description="Indicates if the text is approved or not") approved: bool = Field(...,
score: float = Field(..., description="The score indicating the level of toxicity or appropriateness of the text") description="Indicates if the text is approved or not")
reason: str = Field(..., description="The reason for the moderation decision") score: float = Field(...,
description="The score indicating the level of toxicity or appropriateness of the text")
reason: str = Field(..., description="The reason for the moderation decision")

View File

@@ -0,0 +1,38 @@
from PIL import Image
from app.exceptions.invalid_image_exception import InvalidImageException
class ImageValidator:
MIN_WIDTH = 50
MIN_HEIGHT = 50
MAX_WIDTH = 10000
MAX_HEIGHT = 10000
def validate(
self,
image: Image.Image
) -> None:
width, height = image.size
if width < self.MIN_WIDTH:
raise InvalidImageException(
f"Image width is too small: {width}"
)
if height < self.MIN_HEIGHT:
raise InvalidImageException(
f"Image height is too small: {height}"
)
if width > self.MAX_WIDTH:
raise InvalidImageException(
f"Image width is too large: {width}"
)
if height > self.MAX_HEIGHT:
raise InvalidImageException(
f"Image height is too large: {height}"
)

View File

@@ -0,0 +1,43 @@
import re
from app.moderation.profanity.dictionary import ProfanityDictionary
from app.moderation.profanity.lemmatizer import Lemmatizer
class ProfanityDetector:
def __init__(
self,
dictionary: ProfanityDictionary,
lemmatizer: Lemmatizer
):
self.dictionary = dictionary
self.lemmatizer = lemmatizer
def detect(
self,
text: str
) -> list[str]:
words = re.findall(
r"[а-яА-ЯёЁ]+",
text.lower()
)
result = []
for word in words:
lemma = self.lemmatizer.normalize(word)
for bad_word in self.dictionary.words:
if lemma == bad_word or lemma.startswith(bad_word + "-"):
result.append(word)
break
return result

View File

@@ -0,0 +1,44 @@
from pathlib import Path
class ProfanityDictionary:
def __init__(
self,
file_path: str
):
self.words = self.load(file_path)
def load(
self,
file_path: str
) -> set[str]:
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(
f"Profanity dictionary not found: {file_path}"
)
with open(
path,
"r",
encoding="utf-8"
) as file:
return {
line.strip().lower().replace("\ufeff", "")
for line in file
if line.strip()
}
def contains(
self,
word: str
) -> bool:
return word in self.words

View File

@@ -0,0 +1,15 @@
import pymorphy3
class Lemmatizer:
def __init__(self):
self.morph = pymorphy3.MorphAnalyzer()
def normalize(
self,
word: str
) -> str:
result = self.morph.parse(word)
return result[0].normal_form

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
from PIL import Image, UnidentifiedImageError
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
from app.core.dependencies import get_image_moderation_service
from app.models.response.image_moderation_response import \
ImageModerationResponse
from app.services.image_moderation_service import ImageModerationService
router = APIRouter(
prefix="/api/v1/moderation",
tags=["Moderation"]
)
MAX_SIZE = 5 * 1024 * 1024 # например, 5 МБ
ALLOWED_CONTENT_TYPES = (
"image/jpeg",
"image/png",
"image/webp",
)
@router.post("/image", response_model=ImageModerationResponse)
def moderate_image(
file: UploadFile = File(...),
service: ImageModerationService = Depends(get_image_moderation_service)
):
# 1. Проверка content-type — самая дешёвая, делаем её первой
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=400,
detail="Unsupported image format"
)
# 2. Проверка размера — читаем файл целиком
contents = file.file.read()
if len(contents) > MAX_SIZE:
raise HTTPException(
status_code=413,
detail="Image too large"
)
file.file.seek(0) # обязательно вернуть указатель в начало!
# 3. Только теперь пытаемся открыть изображение
try:
image = Image.open(file.file)
image = image.convert("RGB")
except UnidentifiedImageError:
raise HTTPException(
status_code=400,
detail="Invalid image"
)
prediction = service.moderate(image)
return ImageModerationResponse(
approved=prediction.approved,
score=prediction.score,
reason=prediction.reason,
label=prediction.label,
detected_labels=prediction.detected_labels
)

View File

@@ -1,20 +1,32 @@
from fastapi import APIRouter from fastapi import APIRouter
from typing import Annotated
from fastapi import Depends
from app.models.dto.text_request import TextRequest from app.models.dto.text_request import TextRequest
from app.models.response.moderation_response import ModerationResponse from app.models.response.moderation_response import ModerationResponse
from app.services.text_moderation_service import TextModerationService from app.services.text_moderation_service import TextModerationService
from app.core.dependencies import (
get_text_moderation_service
)
router = APIRouter( router = APIRouter(
prefix="/api/v1/moderation", prefix="/api/v1/moderation",
tags=["Moderation"] tags=["Moderation"]
) )
service = TextModerationService()
@router.post("/text", response_model=ModerationResponse)
def moderate_text(request: TextRequest):
@router.post(
"/text",
response_model=ModerationResponse
)
def moderate_text(
request: TextRequest,
service: Annotated[
TextModerationService,
Depends(get_text_moderation_service)
]
):
prediction = service.moderate(request.text) prediction = service.moderate(request.text)
return ModerationResponse( return ModerationResponse(

View File

@@ -0,0 +1,126 @@
from PIL import Image
from app.config.image_policy import FORBIDDEN_IMAGE_LABELS
from app.config.settings import settings
from app.ml.image.clip_classifier import ClipClassifier
from app.ml.image.image_classifier import ImageClassifier
from app.ml.image.image_prediction_result import ImagePredictionResult
from app.ml.image.weapon_detector import WeaponDetector
from app.moderation.image.validator import ImageValidator
class ImageModerationService:
def __init__(
self,
classifier: ImageClassifier,
validator: ImageValidator,
clip_classifier: ClipClassifier,
weapon_detector: WeaponDetector
):
self.classifier = classifier
self.clip_classifier = clip_classifier
self.weapon_detector = weapon_detector
self.validator = validator
def moderate(
self,
image: Image.Image
) -> ImagePredictionResult:
self.validator.validate(image)
# =========================
# 1. NSFW MODEL
# =========================
nsfw_prediction = self.classifier.predict(image)
if (
nsfw_prediction.label.lower() == "nsfw"
and nsfw_prediction.score >= settings.NSFW_THRESHOLD
):
nsfw_prediction.approved = False
nsfw_prediction.reason = "NSFW"
nsfw_prediction.detected_labels = [
nsfw_prediction.label
]
return nsfw_prediction
# =========================
# 2. WEAPON DETECTOR
# =========================
weapon_prediction = (
self.weapon_detector.predict(image)
)
if weapon_prediction.detected_labels:
return ImagePredictionResult(
label=weapon_prediction.label,
score=weapon_prediction.score,
approved=False,
reason="FORBIDDEN_CONTENT",
detected_labels=weapon_prediction.detected_labels
)
# =========================
# 3. CLIP MODEL
# =========================
clip_prediction = (
self.clip_classifier.predict(image)
)
detected_forbidden = [
label
for label in clip_prediction.detected_labels
if label.lower()
in FORBIDDEN_IMAGE_LABELS
]
if detected_forbidden:
return ImagePredictionResult(
label=clip_prediction.label,
score=clip_prediction.score,
approved=False,
reason="FORBIDDEN_CONTENT",
detected_labels=detected_forbidden
)
# =========================
# 4. NORMAL IMAGE
# =========================
return ImagePredictionResult(
label="normal",
score=1.0,
approved=True,
reason="OK",
detected_labels=[]
)

View File

@@ -1,20 +1,57 @@
from app.exceptions.invalid_text_exception import InvalidTextException
from app.moderation.profanity.detector import ProfanityDetector
from app.ml.text_classifier import TextClassifier from app.ml.text_classifier import TextClassifier
from app.ml.prediction_result import PredictionResult from app.ml.prediction_result import PredictionResult
from app.config.settings import settings
class TextModerationService: class TextModerationService:
TOXIC_THRESHOLD = 0.80 def __init__(
self,
classifier: TextClassifier,
profanity_detector: ProfanityDetector
):
def __init__(self): self.classifier = classifier
self.classifier = TextClassifier() self.profanity_detector = profanity_detector
def moderate(self, text: str) -> PredictionResult: def moderate(
self,
text: str
) -> PredictionResult:
if text is None or not text.strip():
raise InvalidTextException(
"Text is empty"
)
detected_words = (
self.profanity_detector.detect(text)
)
if detected_words:
return PredictionResult(
label="PROFANITY",
score=1.0,
approved=False,
raw_scores=[],
reason="PROFANITY",
detected_words=detected_words
)
prediction = self.classifier.predict(text) prediction = self.classifier.predict(text)
prediction.approved = ( prediction.approved = (
prediction.score < self.TOXIC_THRESHOLD prediction.score <
settings.TEXT_TOXIC_THRESHOLD
) )
prediction.reason = ( prediction.reason = (

View File

@@ -1,20 +0,0 @@
from app.ml.model_manager import model_manager
from app.services.text_moderation_service import TextModerationService
model_manager.initialize()
service = TextModerationService()
texts = [
"Привет",
"Спасибо",
"Ты идиот",
"Хорошего дня"
]
for text in texts:
result = service.moderate(text)
print("=" * 50)
print(text)
print(result)

606
readme.md Normal file
View File

@@ -0,0 +1,606 @@
# AI Moderation Service
Сервис автоматической модерации пользовательского контента на базе **FastAPI** и моделей машинного обучения. Позволяет проверять тексты и изображения на наличие недопустимого контента:
- **Тексты** — токсичность (нейросетевая модель BERT) и нецензурная лексика (словарь + морфологический анализ `pymorphy3`).
- **Изображения** — NSFW-контент (нейросетевая модель `Falconsai/nsfw_image_detection`), оружие (zero-shot детектор **OWLv2**) и запрещённые категории (марихуана, наркотики, порнография, насилие) через мультимодальную модель CLIP.
Сервис предоставляет REST API и возвращает вердикт (`approved`), оценку уверенности (`score`), причину отказа (`reason`) и дополнительную диагностическую информацию.
---
## Оглавление
- [Возможности](#возможности)
- [Технологический стек](#технологический-стек)
- [Структура проекта](#структура-проекта)
- [Установка и запуск](#установка-и-запуск)
- [Конфигурация](#конфигурация)
- [API](#api)
- [POST /api/v1/moderation/text](#post-apiv1moderationtext)
- [POST /api/v1/moderation/image](#post-apiv1moderationimage)
- [Модели данных](#модели-данных)
- [Архитектура и слои](#архитектура-и-слои)
- [Логика модерации текста](#логика-модерации-текста)
- [Логика модерации изображений](#логика-модерации-изображений)
- [Обработка ошибок](#обработка-ошибок)
- [Диагностика и логирование](#диагностика-и-логирование)
- [Известные особенности](#известные-особенности)
---
## Возможности
- **Модерация текста**:
- Проверка на нецензурную лексику по словарю из ~7 350 слов (включая транслит и цифро-замены вроде «6ля»);
- Лемматизация русских слов через `pymorphy3` для поиска всех словоформ;
- Нейросетевая оценка токсичности мультиязычной моделью `textdetox/bert-multilingual-toxicity-classifier`;
- Настраиваемый порог токсичности (по умолчанию `0.90`).
- **Модерация изображений**:
- Проверка формата (JPEG/PNG/WebP) и размера файла (до 5 МБ);
- Валидация геометрии изображения (50×50 … 10000×10000 пикселей);
- Комбинированная проверка: модель NSFW-классификации + CLIP-классификация по запрещённым категориям (наркотики, насилие);
- Отдельный детектор оружия на базе **OWLv2** (zero-shot object detection) — замена ненадёжной CLIP-проверке категории `weapons`;
- Комбинированный вердикт на основе нескольких моделей.
- **Готовый REST API** с автоматической документацией **Swagger UI** (`/docs`) и **ReDoc** (`/redoc`).
- **Автоматическая загрузка ML-моделей** при старте приложения (кэшируются локально).
- **Автовыбор устройства** выполнения инференса: CUDA, если доступен, иначе CPU.
---
## Технологический стек
| Компонент | Технология |
|---|---|
| Веб-фреймворк | FastAPI 0.139 |
| ASGI-сервер | Uvicorn 0.51 |
| Валидация данных | Pydantic 2.13, Pydantic-Settings |
| Работа с переменными окружения | python-dotenv |
| Нейросетевые модели | Transformers (Hugging Face), PyTorch |
| Мультимодальная модель | OpenAI CLIP (ViT-B/32) |
| Детектор оружия | OWLv2 (`google/owlv2-base-patch16-ensemble`) |
| Морфология русского языка | pymorphy3 |
| Обработка изображений | Pillow (PIL) |
| Язык | Python 3.12 |
> **Примечание.** В файле `ai-moderation/requirements.txt` перечислены только базовые зависимости веб-фреймворка. Зависимости машинного обучения (`transformers`, `torch`, `Pillow`, `pymorphy3` и др.) в этом файле **отсутствуют** и должны быть установлены дополнительно — см. раздел [Установка и запуск](#установка-и-запуск).
---
## Структура проекта
```
moderation-post/
├── .env # Конфигурация приложения (переменные окружения)
├── moderation-post.iml # Файл модуля PyCharm (IDEA)
├── .idea/ # Настройки IDE
├── readme.md # Данный документ
└── ai-moderation/ # Корень Python-приложения
├── requirements.txt # Базовые зависимости (без ML-пакетов)
├── models/ # Локальный кэш моделей Hugging Face
├── app/
│ ├── main.py # Точка входа: создание FastAPI-приложения
│ ├── container.py # Ручной DI-контейнер (словарь + детектор)
│ ├── lifespan.py # Жизненный цикл: загрузка моделей при старте
│ ├── config/
│ │ ├── settings.py # Настройки (pydantic-settings), чтение .env
│ │ ├── logging.py # Настройка логирования
│ │ └── image_policy.py # Список запрещённых категорий для изображений
│ ├── core/
│ │ └── dependencies.py # DI-фабрики (lru_cache) для сервисов/классификаторов
│ ├── exceptions/
│ │ ├── moderation_exception.py # Базовое исключение модерации
│ │ ├── invalid_text_exception.py # Ошибка «некорректный текст»
│ │ ├── invalid_image_exception.py # Ошибка «некорректное изображение»
│ │ └── handlers.py # Обработчики ошибок → JSON
│ ├── ml/
│ │ ├── base_classifier.py # Абстрактный базовый классификатор
│ │ ├── model_manager.py # Синглтон загрузки и хранения моделей
│ │ ├── prediction_result.py # Результат классификации текста
│ │ ├── text_classifier.py # Классификатор токсичности текста
│ │ └── image/
│ │ ├── image_classifier.py # Классификатор NSFW изображений
│ │ ├── image_prediction_result.py # Результат классификации изображений
│ │ ├── clip_classifier.py # CLIP-классификатор по категориям
│ │ ├── clip_prediction_result.py # Результат CLIP-классификации
│ │ ├── weapon_detector.py # Детектор оружия (OWLv2)
│ │ └── weapon_prediction_result.py # Результат детекции оружия
│ ├── models/
│ │ ├── dto/
│ │ │ └── text_request.py # DTO запроса модерации текста
│ │ └── response/
│ │ ├── moderation_response.py # Ответ для текста
│ │ ├── image_moderation_response.py # Ответ для изображения
│ │ └── error_response.py # Универсальный ответ об ошибке
│ ├── moderation/
│ │ ├── image/
│ │ │ └── validator.py # Проверка размеров изображения
│ │ └── profanity/
│ │ ├── dictionary.py # Загрузка словаря нецензурной лексики
│ │ ├── lemmatizer.py # Лемматизация через pymorphy3
│ │ └── detector.py # Детектор нецензурной лексики в тексте
│ ├── resources/
│ │ └── profanity_words.txt # Словарь нецензурных слов (~7 350 записей)
│ ├── routers/
│ │ ├── text_moderation_router.py # Эндпоинт POST /api/v1/moderation/text
│ │ └── image_moderation_router.py # Эндпоинт POST /api/v1/moderation/image
│ └── services/
│ ├── text_moderation_service.py # Бизнес-логика модерации текста
│ └── image_moderation_service.py # Бизнес-логика модерации изображений
└── venv/ # Виртуальное окружение Python
```
---
## Установка и запуск
### 1. Клонирование репозитория
```bash
git clone <url-репозитория>
cd moderation-post
```
### 2. Создание виртуального окружения
```bash
cd ai-moderation
python -m venv venv
# Активация (Windows)
venv\Scripts\activate
# или (Linux/macOS)
source venv/bin/activate
```
### 3. Установка зависимостей
Файл `requirements.txt` содержит только веб-зависимости. Поскольку в `requirements.txt` не входят ML-пакеты, установите зависимости полностью:
```bash
pip install -r requirements.txt
```
Дополнительно (необходимо для работы ML-части, но не перечислено в `requirements.txt`):
```bash
pip install torch
pip install transformers
pip install Pillow
pip install pymorphy3
```
> **Важно.** Для работы на GPU установите `torch` с соответствующим индексом CUDA (см. официальную документацию PyTorch), либо оставьте CPU-версию — сервис автоматически выберет доступное устройство.
### 4. Настройка окружения
Создайте файл `.env` в корне проекта (рядом с каталогом `ai-moderation`) или в самом каталоге приложения. Минимальный рабочий конфиг:
```dotenv
APP_NAME=AI Moderation Service
APP_VERSION=1.0.0
HOST=0.0.0.0
PORT=8000
DEBUG=true
# ===== AI =====
TEXT_MODEL=textdetox/bert-multilingual-toxicity-classifier
IMAGE_MODEL=Falconsai/nsfw_image_detection
IMAGE_CLIP_MODEL=openai/clip-vit-base-patch32
WEAPON_MODEL=google/owlv2-base-patch16-ensemble
MODEL_CACHE_DIR=./models
DEVICE=auto
HF_TOKEN=<ваш HF-токен>
```
> **Внимание:** в файле `.env` и в `settings.py` по умолчанию уже вписаны значения `HF_TOKEN`, в том числе **реальный токен**. Рекомендуется не публиковать его и заменить на собственный. Публиковать секреты в репозиторий недопустимо.
### 5. Запуск сервера
Из каталога `ai-moderation`:
```bash
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
или через FastAPI CLI:
```bash
fastapi run app/main.py --host 0.0.0.0 --port 8000
```
При старте сервис скачает указанные модели в каталог `MODEL_CACHE_DIR` (если их ещё нет) и загрузит их в память. На машине без GPU первая загрузка может занять несколько минут.
### 6. Проверка работоспособности
- Интерактивная документация API: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
---
## Конфигурация
Настройки определяются классом `Settings` в `app/config/settings.py` (pydantic-settings) и читаются из файла `.env`. Все параметры имеют значения по умолчанию.
| Переменная | Тип | По умолчанию | Описание |
|---|---|---|---|
| `APP_NAME` | str | `AI Moderation` | Название сервиса (отображается в Swagger) |
| `APP_VERSION` | str | `1.0.0` | Версия сервиса |
| `HOST` | str | `0.0.0.0` | Хост для запуска Uvicorn |
| `PORT` | int | `8000` | Порт для запуска Uvicorn |
| `DEBUG` | bool | `True` | Режим отладки |
| `TEXT_TOXIC_THRESHOLD` | float | `0.90` | Порог токсичности текста: если вероятность «токсично» ≥ порога — текст отклоняется |
| `TEXT_MODEL` | str | `textdetox/bert-multilingual-toxicity-classifier` | Модель классификации токсичности текста |
| `IMAGE_MODEL` | str | `Falconsai/nsfw_image_detection` | Модель NSFW-классификации изображений |
| `NSFW_THRESHOLD` | float | `0.85` | Порог уверенности NSFW-модели |
| `IMAGE_CLIP_MODEL` | str | `openai/clip-vit-base-patch32` | Мультимодальная модель CLIP |
| `CLIP_THRESHOLD` | float | `0.75` | Порог уверенности CLIP-классификатора |
| `WEAPON_MODEL` | str | `google/owlv2-base-patch16-ensemble` | Модель zero-shot детекции оружия (OWLv2) |
| `WEAPON_THRESHOLD` | float | `0.40` | Порог уверенности детектора оружия |
| `WEAPON_MIN_AREA_FRACTION` | float | `0.005` | Минимальная доля площади изображения для бокса (0.5%), отсекает мелкие ложные срабатывания |
| `HF_TOKEN` | str | — | Токен Hugging Face (подписанный/приватные модели) |
| `MODEL_CACHE_DIR` | str | `./models` | Каталог локального кэша моделей |
| `DEVICE` | str | `auto` | Устройство инференса: `auto`, `cpu`, `cuda` и т.д. |
---
## API
Все эндпоинты находятся под префиксом `/api/v1/moderation`. Сервис не содержит авторизации — эндпоинты открыты.
### POST /api/v1/moderation/text
Модерация текста. Запрос принимает JSON.
**Тело запроса** (модель `TextRequest`):
```json
{
"text": "Пример проверяемого текста"
}
```
| Поле | Тип | Ограничения |
|---|---|---|
| `text` | string | обязательное, длина от 1 до 5000 символов |
**Успешный ответ — `200 OK`** (модель `ModerationResponse`):
```json
{
"approved": true,
"score": 0.015,
"reason": "OK"
}
```
| Поле | Тип | Описание |
|---|---|---|
| `approved` | boolean | `true` — контент разрешён, `false` — отклонён |
| `score` | float | Уровень токсичности (0.0 … 1.0) |
| `reason` | string | Причина решения: `OK`, `TOXIC`, `PROFANITY` |
**Коды ошибок:**
- `400` — пустой текст / невалидная длина (`INVALID_TEXT`).
---
### POST /api/v1/moderation/image
Модерация изображения. Запрос — `multipart/form-data` с полем `file` (тип `UploadFile`).
**Ограничения загрузки:**
- Допустимые MIME-типы: `image/jpeg`, `image/png`, `image/webp`;
- Максимальный размер файла: 5 МБ;
- Допустимые размеры изображения: ширина и высота от 50 до 10000 пикселей.
**Успешный ответ — `200 OK`** (модель `ImageModerationResponse`):
```json
{
"approved": true,
"score": 1.0,
"reason": "OK",
"label": "normal",
"detected_labels": []
}
```
Пример ответа при отклонении:
```json
{
"approved": false,
"score": 0.93,
"reason": "NSFW",
"label": "nsfw",
"detected_labels": ["nsfw"]
}
```
| Поле | Тип | Описание |
|---|---|---|
| `approved` | boolean | Разрешено ли изображение |
| `score` | float | Уверенность модели, на которой принято решение |
| `reason` | string | Причина: `OK`, `NSFW`, `FORBIDDEN_CONTENT` |
| `label` | string | Наиболее вероятная метка (например, `normal`, `nsfw` или метка CLIP) |
| `detected_labels` | array of strings | Список обнаруженных запрещённых категорий |
**Коды ошибок:**
- `400` — недопустимый формат (`Unsupported image format`), битый файл (`Invalid image`), слишком маленькое/большое изображение (`INVALID_IMAGE`);
- `413` — файл больше 5 МБ (`Image too large`).
---
## Модели данных
### Запросы (DTO)
**`TextRequest`** (`app/models/dto/text_request.py`)
```python
class TextRequest(BaseModel):
text: str = Field(min_length=1, max_length=5000)
```
### Ответы
**`ModerationResponse`** (`app/models/response/moderation_response.py`) — ответ для текста:
```python
class ModerationResponse(BaseModel):
approved: bool
score: float
reason: str
```
**`ImageModerationResponse`** (`app/models/response/image_moderation_response.py`) — ответ для изображения:
```python
class ImageModerationResponse(BaseModel):
approved: bool
score: float
reason: str
label: str
detected_labels: List[str]
```
**`ErrorResponse`** (`app/models/response/error_response.py`) — универсальный ответ об ошибке:
```python
class ErrorResponse(BaseModel):
code: str
message: str
```
### Внутренние структуры (ML)
**`PredictionResult`** (`app/ml/prediction_result.py`) — результат классификации текста:
```python
@dataclass(slots=True)
class PredictionResult:
label: str
score: float
approved: bool
raw_scores: list[float]
reason: str = ""
detected_words: list[str] = field(default_factory=list)
```
**`ImagePredictionResult`** (`app/ml/image/image_prediction_result.py`) — результат классификации изображения:
```python
@dataclass(slots=True)
class ImagePredictionResult:
label: str
score: float
approved: bool
raw_scores: list[float] = field(default_factory=list)
reason: str = ""
detected_labels: list[str] = field(default_factory=list)
```
**`ClipPredictionResult`** (`app/ml/image/clip_prediction_result.py`) — результат CLIP-классификации:
```python
@dataclass(slots=True)
class ClipPredictionResult:
label: str # метка с максимальной вероятностью
score: float # вероятность метки
detected_labels: list[str] # метки выше порога CLIP_THRESHOLD
scores: dict[str, float] # все метки → вероятности
```
---
## Архитектура и слои
Проект построен по классической слоистой схеме:
```
Роутеры (routers)
↓
Сервисы (services) — бизнес-логика модерации
↓
Классификаторы (ml) + Правила (moderation)
↓
Модели Hugging Face / словарь нецензурной лексики
```
Зависимости компонентов внедряются через фабрики с декоратором `@lru_cache` (`app/core/dependencies.py`), поэтому в рамках одного процесса создаётся по одному экземпляру каждого классификатора и сервиса. Загрузка моделей выполняется один раз в `lifespan` при старте приложения.
### Точка входа — `app/main.py`
- Создаёт экземпляр `FastAPI` с названием и версией из настроек;
- Подключает роутеры модерации текста и изображений;
- Регистрирует глобальные обработчики исключений `InvalidTextException` и `InvalidImageException`;
- Жизненный цикл приложения задан в `lifespan`.
### Жизненный цикл — `app/lifespan.py`
При старте:
1. Если задан `HF_TOKEN`, он экспортируется в переменную окружения `HF_TOKEN`;
2. Вызывается `model_manager.initialize()` — загрузка устройства, токенизатора, текстовой и графической моделей.
При остановке сервис просто логирует завершение работы (освобождение GPU-памяти реализовано на уровне процесса/ОС).
### Конфигурация — `app/config/`
- **`settings.py`** — класс `Settings` (pydantic-settings) + экземпляр `settings`. Загружает `.env`, игнорирует лишние ключи (`extra="ignore"`).
- **`logging.py`** — конфигурация логирования в stdout (`logging.INFO`), логгер `ai-moderation`.
- **`image_policy.py`** — множество `FORBIDDEN_IMAGE_LABELS` с запрещёнными категориями CLIP:
- `a photo containing marijuana`
- `a photo containing drugs`
- `a photo containing violence`
Порнография и оружие в этот список не входят: первая обрабатывается NSFW-моделью (шаг 1), второе — отдельным детектором на базе OWLv2 (шаг 2).
### DI-фабрики — `app/core/dependencies.py`
Использует `@lru_cache`, что превращает фабрики в синглтоны:
| Фабрика | Возвращает |
|---|---|
| `get_text_classifier` | `TextClassifier` |
| `get_clip_classifier` | `ClipClassifier` |
| `get_image_classifier` | `ImageClassifier` |
| `get_weapon_detector` | `WeaponDetector` (OWLv2) |
| `get_profanity_detector` | `ProfanityDetector` (словарь + лемматизатор) |
| `get_text_moderation_service` | `TextModerationService` |
| `get_image_moderation_service` | `ImageModerationService` (классификатор + валидатор + CLIP + детектор оружия) |
| `get_image_validator` | `ImageValidator` |
> Файл `app/container.py` содержит аналогичный, но неиспользуемый «ручной» контейнер, создающий детектор нецензурной лексики. Основной путь внедрения зависимостей — через `core/dependencies.py`.
### Слой ML — `app/ml/`
- **`base_classifier.py`** — абстрактный класс `BaseClassifier` с методом `predict(value)`;
- **`model_manager.py`** — синглтон `model_manager`, который:
- выбирает устройство (`auto` → CUDA при доступности, иначе CPU);
- загружает токенизатор и текстовую модель (`AutoTokenizer`, `AutoModelForSequenceClassification`);
- загружает процессор и модель изображений (`AutoImageProcessor`, `AutoModelForImageClassification`);
- переводит модели в режим `eval()`;
- **`text_classifier.py`** — `TextClassifier.predict(text)`:
- токенизирует текст (макс. 512 токенов, `truncation`, `padding`);
- прогоняет через модель без градиентов (`torch.no_grad`);
- применяет `softmax` к логитам;
- возвращает `PredictionResult` со `score = вероятность класса 1` (токсичный);
- **`image/image_classifier.py`** — `ImageClassifier.predict(image)`:
- обрабатывает изображение процессором модели;
- возвращает `ImagePredictionResult` с меткой из `id2label` (обычно `nsfw`/`normal`);
- **`image/clip_classifier.py`** — `ClipClassifier`:
- загружает CLIP-модель и процессор сразу в конструкторе (при первом обращении);
- задаёт список из 4 текстовых меток (`a normal photo` + 3 запрещённые: наркотики, насилие). Порнография отдельно обрабатывается NSFW-моделью на шаге 1;
- считает вероятности соответствия изображения каждой метке;
- собирает метки, превышающие `CLIP_THRESHOLD`, и возвращает `ClipPredictionResult`;
- **`image/weapon_detector.py`** — `WeaponDetector`:
- загружает OWLv2-модель и процессор (zero-shot object detection) при первом обращении;
- ищет по 15 текстовым запросам: `gun`, `pistol`, `rifle`, `shotgun`, `knife`, `machete`, `sword`, `bomb`, `grenade` и др.;
- через `post_process_object_detection` получает боксы и уверенность по каждой запрошенной категории;
- отсеивает боксы меньше `WEAPON_MIN_AREA_FRACTION` от площади изображения и с уверенностью ниже `WEAPON_THRESHOLD`;
- возвращает `WeaponPredictionResult` с найденными категориями.
### Слой правил — `app/moderation/`
**Нецензурная лексика (`profanity/`):**
- **`dictionary.py`** — `ProfanityDictionary` загружает словарь из текстового файла в множество (`set`), убирает BOM и пустые строки, приводит к нижнему регистру. Метод `contains(word)` проверяет точное вхождение.
- **`lemmatizer.py`** — `Lemmatizer` на базе `pymorphy3.MorphAnalyzer`, метод `normalize(word)` возвращает нормальную форму (лемму) слова.
- **`detector.py`** — `ProfanityDetector.detect(text)`:
1. регулярным выражением `[а-яА-ЯёЁ]+` извлекает русскоязычные слова (после приведения к нижнему регистру);
2. каждое слово лемматизирует;
3. проверяет, начинается ли лемма с какого-либо слова из словаря (префиксное сравнение через `startswith`);
4. возвращает список найденных слов в исходном виде.
**Валидация изображений (`image/validator.py`):**
`ImageValidator.validate(image)` проверяет размеры изображения и выбрасывает `InvalidImageException` с описанием проблемы, если:
- ширина/высота меньше `MIN_WIDTH/MIN_HEIGHT` (50);
- ширина/высота больше `MAX_WIDTH/MAX_HEIGHT` (10000).
---
## Логика модерации текста
`TextModerationService.moderate(text)` (`app/services/text_moderation_service.py`):
1. **Проверка входа.** Если текст `None` или состоит из пробелов — выбрасывается `InvalidTextException("Text is empty")` → HTTP 400.
2. **Детектор нецензурной лексики.** `profanity_detector.detect(text)`:
- если найдены запрещённые слова, сразу возвращается `PredictionResult(label="PROFANITY", score=1.0, approved=False, reason="PROFANITY", detected_words=[...])` — нейросеть не вызывается (экономия ресурсов);
3. **Нейросетевая оценка.** Иначе вызывается `TextClassifier.predict(text)`:
- `approved = score < TEXT_TOXIC_THRESHOLD`;
- `reason = "OK"` при одобрении, иначе `"TOXIC"`.
Итоговое решение передаётся в роутер и маппится в `ModerationResponse`.
---
## Логика модерации изображений
`ImageModerationService.moderate(image)` (`app/services/image_moderation_service.py`):
1. **Валидация.** `ImageValidator.validate(image)` — проверка размеров (см. выше).
2. **Шаг 1 — NSFW-модель.** `ImageClassifier.predict(image)`:
- если метка `nsfw` и уверенность ≥ `NSFW_THRESHOLD` (0.85) → решение **отклонить**: `reason="NSFW"`, `detected_labels=["nsfw"]`;
3. **Шаг 2 — детектор оружия.** `WeaponDetector.predict(image)`:
- zero-shot детекция по запросам оружия (OWLv2, боксы + уверенность);
- если найдена категория с уверенностью ≥ `WEAPON_THRESHOLD` и боксом больше `WEAPON_MIN_AREA_FRACTION` → решение **отклонить**: `reason="FORBIDDEN_CONTENT"`, `detected_labels=[...]`;
4. **Шаг 3 — CLIP-модель.** `ClipClassifier.predict(image)`:
- из меток, превысивших `CLIP_THRESHOLD`, отбираются только те, что входят в `FORBIDDEN_IMAGE_LABELS` (наркотики, насилие);
- если такие категории найдены → решение **отклонить**: `reason="FORBIDDEN_CONTENT"`, `detected_labels=[...]`;
5. **Шаг 4 — «нормальное» изображение.** Если ни одно из правил не сработало → `approved=True`, `reason="OK"`, `label="normal"`, `score=1.0`.
Таким образом, изображение одобряется только если оно прошло и NSFW-модель, и детектор оружия, и CLIP-проверку по запрещённым категориям.
---
## Обработка ошибок
| Класс | Наследует | Назначение |
|---|---|---|
| `ModerationException` | `Exception` | Базовый класс, хранит поле `message` |
| `InvalidTextException` | `ModerationException` | Некорректный текст (пустой) |
| `InvalidImageException` | `ModerationException` | Некорректное изображение (размеры) |
Обработчики в `app/exceptions/handlers.py` преобразуют исключения в HTTP-ответ:
| Исключение | HTTP-код | `code` в теле |
|---|---|---|
| `InvalidTextException` | 400 | `INVALID_TEXT` |
| `InvalidImageException` | 400 | `INVALID_IMAGE` |
Тело ошибки всегда в формате `ErrorResponse`:
```json
{
"code": "INVALID_IMAGE",
"message": "Image width is too small: 10"
}
```
Ошибки загрузки файла (формат/размер) обрабатываются в роутере напрямую через `HTTPException` с соответствующими кодами (`400`, `413`).
---
## Диагностика и логирование
- Логи пишутся в stdout с уровнем `INFO` в формате `время уровень сообщение`.
- При старте логируются шаги загрузки моделей и выбранное устройство.
- В классификаторах присутствуют отладочные `print`-вызовы:
- `TextClassifier` печатает входные тензоры, логиты и вероятности;
- `ImageClassifier` печатает метки, предсказание и вероятности;
- `ClipClassifier` печатает выбранное устройство при инициализации.
---
## Известные особенности
1. **Неполный `requirements.txt`.** В файл не включены ML-зависимости (`torch`, `transformers`, `Pillow`, `pymorphy3`), хотя они обязательны для работы. Для воспроизводимой установки их следует добавить в `requirements.txt`.
2. **`HF_TOKEN` зашит по умолчанию.** Токен присутствует и в `.env`, и как значение по умолчанию в `settings.py`. Его необходимо заменить/вычистить перед публикацией проекта.
3. **Отладочный вывод.** Классификаторы содержат `print()`-вызовы; в «боевом» режиме их стоит заменить на логирование через `logger`.
4. **Дублирование DI.** Логика внедрения зависимостей дублируется в `app/container.py` (не используется) и в `app/core/dependencies.py` (основной путь).
5. **Производительность.** Модели загружаются в память целиком; на CPU инференс CLIP-модели может быть медленным. Загрузка моделей происходит при старте приложения (см. `lifespan`), первое обращение к CLIP-эндпоинту также выполняет инициализацию модели.