Compare commits
10 Commits
1132a774af
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
848f40846a | ||
|
|
061bad8f67 | ||
|
|
30c29d8280 | ||
|
|
3ab97895af | ||
|
|
327394a22a | ||
|
|
4c32c5f4dd | ||
|
|
fdf2e2a8d5 | ||
|
|
d33f0eaa78 | ||
|
|
72159dbe4a | ||
|
|
d376a7417a |
2
.env
2
.env
@@ -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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"
|
||||||
8
ai-moderation/app/config/image_policy.py
Normal file
8
ai-moderation/app/config/image_policy.py
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
65
ai-moderation/app/core/dependencies.py
Normal file
65
ai-moderation/app/core/dependencies.py
Normal 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()
|
||||||
|
)
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
from app.ml.model_manager import model_manager
|
|
||||||
|
|
||||||
def get_model_manager():
|
|
||||||
return model_manager
|
|
||||||
28
ai-moderation/app/exceptions/handlers.py
Normal file
28
ai-moderation/app/exceptions/handlers.py
Normal 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())
|
||||||
5
ai-moderation/app/exceptions/invalid_image_exception.py
Normal file
5
ai-moderation/app/exceptions/invalid_image_exception.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from app.exceptions.moderation_exception import ModerationException
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidImageException(ModerationException):
|
||||||
|
pass
|
||||||
6
ai-moderation/app/exceptions/invalid_text_exception.py
Normal file
6
ai-moderation/app/exceptions/invalid_text_exception.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from app.exceptions.moderation_exception import ModerationException
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTextException(ModerationException):
|
||||||
|
|
||||||
|
pass
|
||||||
4
ai-moderation/app/exceptions/moderation_exception.py
Normal file
4
ai-moderation/app/exceptions/moderation_exception.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
class ModerationException(Exception):
|
||||||
|
def __init__(self, message: str):
|
||||||
|
self.message = message
|
||||||
|
super().__init__(message)
|
||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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)
|
||||||
59
ai-moderation/app/ml/image/clip_classifier.py
Normal file
59
ai-moderation/app/ml/image/clip_classifier.py
Normal 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
|
||||||
|
)
|
||||||
12
ai-moderation/app/ml/image/clip_prediction_result.py
Normal file
12
ai-moderation/app/ml/image/clip_prediction_result.py
Normal 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]
|
||||||
69
ai-moderation/app/ml/image/image_classifier.py
Normal file
69
ai-moderation/app/ml/image/image_classifier.py
Normal 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
|
||||||
|
)
|
||||||
18
ai-moderation/app/ml/image/image_prediction_result.py
Normal file
18
ai-moderation/app/ml/image/image_prediction_result.py
Normal 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
|
||||||
|
)
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
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:
|
||||||
@@ -13,6 +15,13 @@ class ModelManager:
|
|||||||
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 _setup_hf_token(self):
|
||||||
|
if settings.HF_TOKEN:
|
||||||
|
os.environ["HF_TOKEN"] = settings.HF_TOKEN
|
||||||
|
os.environ["HUGGING_FACE_HUB_TOKEN"] = settings.HF_TOKEN
|
||||||
|
|
||||||
def load_device(self):
|
def load_device(self):
|
||||||
if settings.DEVICE == "auto":
|
if settings.DEVICE == "auto":
|
||||||
@@ -23,34 +32,44 @@ class ModelManager:
|
|||||||
self.device = torch.device(settings.DEVICE)
|
self.device = torch.device(settings.DEVICE)
|
||||||
|
|
||||||
def load_tokenizer(self):
|
def load_tokenizer(self):
|
||||||
print("Loading tokenizer...")
|
logger.info("Loading tokenizer...")
|
||||||
|
|
||||||
self.text_tokenizer = AutoTokenizer.from_pretrained(
|
self.text_tokenizer = AutoTokenizer.from_pretrained(
|
||||||
settings.TEXT_MODEL,
|
settings.TEXT_MODEL,
|
||||||
cache_dir=settings.MODEL_CACHE_DIR
|
cache_dir=settings.MODEL_CACHE_DIR
|
||||||
)
|
)
|
||||||
|
logger.info("Tokenizer loaded.")
|
||||||
print("Tokenizer loaded.")
|
|
||||||
|
|
||||||
def load_model(self):
|
def load_model(self):
|
||||||
print("Loading model...")
|
logger.info("Loading model...")
|
||||||
|
|
||||||
self.text_model = AutoModelForSequenceClassification.from_pretrained(
|
self.text_model = AutoModelForSequenceClassification.from_pretrained(
|
||||||
settings.TEXT_MODEL,
|
settings.TEXT_MODEL,
|
||||||
cache_dir=settings.MODEL_CACHE_DIR
|
cache_dir=settings.MODEL_CACHE_DIR
|
||||||
)
|
)
|
||||||
|
|
||||||
self.text_model.to(self.device)
|
self.text_model.to(self.device)
|
||||||
self.text_model.eval()
|
self.text_model.eval()
|
||||||
print(self.text_model.config.id2label)
|
logger.debug("id2label: %s", self.text_model.config.id2label)
|
||||||
|
logger.info("Text model loaded.")
|
||||||
|
|
||||||
print("Text model loaded.")
|
def load_image_model(self):
|
||||||
|
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.")
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.load_device()
|
self.load_device()
|
||||||
print(f"Using device: {self.device}")
|
logger.info(f"Using device: {self.device}")
|
||||||
self.load_tokenizer()
|
self.load_tokenizer()
|
||||||
self.load_model()
|
self.load_model()
|
||||||
|
self.load_image_model()
|
||||||
|
|
||||||
|
|
||||||
model_manager = ModelManager()
|
model_manager = ModelManager()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
6
ai-moderation/app/models/response/error_response.py
Normal file
6
ai-moderation/app/models/response/error_response.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
@@ -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]
|
||||||
@@ -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")
|
||||||
|
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")
|
reason: str = Field(..., description="The reason for the moderation decision")
|
||||||
38
ai-moderation/app/moderation/image/validator.py
Normal file
38
ai-moderation/app/moderation/image/validator.py
Normal 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}"
|
||||||
|
)
|
||||||
43
ai-moderation/app/moderation/profanity/detector.py
Normal file
43
ai-moderation/app/moderation/profanity/detector.py
Normal 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
|
||||||
44
ai-moderation/app/moderation/profanity/dictionary.py
Normal file
44
ai-moderation/app/moderation/profanity/dictionary.py
Normal 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
|
||||||
15
ai-moderation/app/moderation/profanity/lemmatizer.py
Normal file
15
ai-moderation/app/moderation/profanity/lemmatizer.py
Normal 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
|
||||||
7352
ai-moderation/app/resources/profanity_words.txt
Normal file
7352
ai-moderation/app/resources/profanity_words.txt
Normal file
File diff suppressed because it is too large
Load Diff
64
ai-moderation/app/routers/image_moderation_router.py
Normal file
64
ai-moderation/app/routers/image_moderation_router.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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(
|
||||||
126
ai-moderation/app/services/image_moderation_service.py
Normal file
126
ai-moderation/app/services/image_moderation_service.py
Normal 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=[]
|
||||||
|
|
||||||
|
)
|
||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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
606
readme.md
Normal 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-эндпоинту также выполняет инициализацию модели.
|
||||||
Reference in New Issue
Block a user