diff --git a/.env b/.env
deleted file mode 100644
index 5771afd..0000000
--- a/.env
+++ /dev/null
@@ -1,19 +0,0 @@
-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
-
-MODEL_CACHE_DIR=./models
-
-DEVICE=auto
-
-HF_TOKEN: str = "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"
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..131180d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,30 @@
+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
+TEXT_TOXIC_THRESHOLD=0.90
+
+IMAGE_MODEL=Falconsai/nsfw_image_detection
+NSFW_THRESHOLD=0.85
+
+IMAGE_CLIP_MODEL=openai/clip-vit-base-patch32
+CLIP_THRESHOLD=0.75
+
+WEAPON_MODEL=google/owlv2-base-patch16-ensemble
+WEAPON_THRESHOLD=0.40
+WEAPON_MIN_AREA_FRACTION=0.005
+
+MODEL_CACHE_DIR=./models
+
+DEVICE=auto
+
+HF_TOKEN=<ваш-токен-hugging-face>
\ No newline at end of file
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..8f3cd1f
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,16 @@
+name: deploy-ai
+on:
+ push:
+ branches: [main]
+
+jobs:
+ deploy:
+ runs-on: host
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build image
+ run: docker build -t nakhodka-ai:latest -t nakhodka-ai:${{ github.sha }} ai-moderation
+ - name: Restart ai-service
+ run: docker compose -f /opt/nakhodka/docker-compose.yml up -d ai-service
+ - name: Cleanup
+ run: docker image prune -f
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..247ffb8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.env
+.idea/
+__pycache__/
+*.pyc
+venv/
+.venv/
+ai-moderation/models/
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b8..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/amplicode-jpa.xml b/.idea/amplicode-jpa.xml
deleted file mode 100644
index 98351bf..0000000
--- a/.idea/amplicode-jpa.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 39f665b..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index c65439d..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/ai-moderation/.dockerignore b/ai-moderation/.dockerignore
new file mode 100644
index 0000000..65171e6
--- /dev/null
+++ b/ai-moderation/.dockerignore
@@ -0,0 +1,8 @@
+__pycache__
+*.pyc
+.env
+venv
+.venv
+models
+.idea
+.git
\ No newline at end of file
diff --git a/ai-moderation/Dockerfile b/ai-moderation/Dockerfile
new file mode 100644
index 0000000..68d3bc0
--- /dev/null
+++ b/ai-moderation/Dockerfile
@@ -0,0 +1,22 @@
+FROM python:3.12-slim
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1
+WORKDIR /app
+
+# torch ставим отдельно в облегчённой версии для процессора (без CUDA)
+RUN pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
+
+COPY requirements.txt .
+RUN pip install -r requirements.txt
+
+COPY app ./app
+
+RUN useradd -r -u 1001 app \
+ && mkdir -p /models \
+ && chown app /models
+USER app
+
+ENV MODEL_CACHE_DIR=/models
+EXPOSE 8000
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
diff --git a/ai-moderation/app/ml/image/weapon_detector.py b/ai-moderation/app/ml/image/weapon_detector.py
new file mode 100644
index 0000000..d49eded
--- /dev/null
+++ b/ai-moderation/app/ml/image/weapon_detector.py
@@ -0,0 +1,109 @@
+import torch
+from PIL import Image
+from transformers import Owlv2ForObjectDetection, Owlv2Processor
+
+from app.config.settings import settings
+from app.ml.image.weapon_prediction_result import WeaponPredictionResult
+
+
+class WeaponDetector:
+
+ QUERIES = [
+ "a gun",
+ "a pistol",
+ "a revolver",
+ "a rifle",
+ "a shotgun",
+ "an assault rifle",
+ "a knife",
+ "a machete",
+ "a sword",
+ "a bomb",
+ "a grenade",
+ "a crossbow",
+ "a bow and arrow",
+ "brass knuckles",
+ "a nunchaku"
+ ]
+
+ def __init__(self):
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
+ print(f"Loading OWLv2 weapon detector on {self.device}")
+
+ self.model = Owlv2ForObjectDetection.from_pretrained(
+ settings.WEAPON_MODEL
+ )
+ self.processor = Owlv2Processor.from_pretrained(
+ settings.WEAPON_MODEL
+ )
+
+ self.model.to(self.device)
+ self.model.eval()
+
+ def predict(
+ self,
+ image: Image.Image
+ ) -> WeaponPredictionResult:
+ inputs = self.processor(
+ text=self.QUERIES,
+ images=image,
+ return_tensors="pt"
+ )
+
+ inputs = {
+ key: value.to(self.device)
+ for key, value in inputs.items()
+ }
+
+ with torch.no_grad():
+ outputs = self.model(**inputs)
+
+ target_sizes = torch.tensor([image.size[::-1]])
+
+ results = self.processor.post_process_grounded_object_detection(
+ outputs,
+ threshold=0.0,
+ target_sizes=target_sizes
+ )[0]
+
+ scores = results["scores"].tolist()
+ labels = results["labels"].tolist()
+ boxes = results["boxes"].tolist()
+
+ image_area = image.width * image.height
+ label_scores = {}
+
+ for score, label_index, box in zip(scores, labels, boxes):
+ box_area = (box[2] - box[0]) * (box[3] - box[1])
+
+ if box_area < image_area * settings.WEAPON_MIN_AREA_FRACTION:
+ continue
+
+ if score >= settings.WEAPON_THRESHOLD:
+ query = self.QUERIES[label_index]
+ label_scores[query] = max(
+ label_scores.get(query, 0.0),
+ score
+ )
+
+ if not label_scores:
+ return WeaponPredictionResult(
+ label="normal",
+ score=0.0
+ )
+
+ best_label = max(
+ label_scores,
+ key=label_scores.get
+ )
+
+ print("====================")
+ print("WEAPON RESULT")
+ print("Scores:", label_scores)
+
+ return WeaponPredictionResult(
+ label=best_label,
+ score=label_scores[best_label],
+ detected_labels=sorted(label_scores.keys()),
+ box_count=len(label_scores)
+ )
\ No newline at end of file
diff --git a/ai-moderation/app/ml/image/weapon_prediction_result.py b/ai-moderation/app/ml/image/weapon_prediction_result.py
new file mode 100644
index 0000000..8ca967b
--- /dev/null
+++ b/ai-moderation/app/ml/image/weapon_prediction_result.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass, field
+
+
+@dataclass(slots=True)
+class WeaponPredictionResult:
+ label: str
+
+ score: float
+
+ detected_labels: list[str] = field(
+ default_factory=list
+ )
+
+ box_count: int = 0
\ No newline at end of file
diff --git a/ai-moderation/requirements.txt b/ai-moderation/requirements.txt
new file mode 100644
index 0000000..f2a7256
Binary files /dev/null and b/ai-moderation/requirements.txt differ