Spaces:
Runtime error
Runtime error
Deploy SafeChat ML service to Hugging Face Space
Browse files- .dockerignore +21 -7
- Dockerfile +27 -10
- README.md +21 -43
- app/__init__.py +1 -1
- app/api/__init__.py +1 -0
- app/api/dependencies.py +22 -0
- app/api/routes/__init__.py +1 -0
- app/api/routes/detoxify.py +45 -0
- app/api/routes/feedback.py +47 -0
- app/api/routes/health.py +46 -0
- app/api/routes/moderation.py +70 -0
- app/config.py +75 -26
- app/main.py +106 -89
- app/models/__init__.py +1 -0
- app/models/detoxifier.py +359 -0
- app/models/model_manager.py +89 -0
- app/models/toxicity_classifier.py +603 -0
- app/schemas/__init__.py +1 -0
- app/schemas/feedback.py +50 -0
- app/schemas/moderation.py +85 -0
- app/services/__init__.py +1 -0
- app/services/feedback_service.py +139 -0
- app/services/moderation_service.py +104 -0
- app/utils/__init__.py +1 -0
- app/utils/preprocessing.py +257 -0
- requirements.txt +30 -9
.dockerignore
CHANGED
|
@@ -1,10 +1,24 @@
|
|
| 1 |
-
|
| 2 |
-
.gitignore
|
| 3 |
-
.env
|
| 4 |
-
.tmp
|
| 5 |
-
__pycache__
|
| 6 |
*.pyc
|
| 7 |
*.pyo
|
| 8 |
*.pyd
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
*.pyc
|
| 3 |
*.pyo
|
| 4 |
*.pyd
|
| 5 |
+
.env
|
| 6 |
+
.env.*
|
| 7 |
+
tests/
|
| 8 |
+
training/
|
| 9 |
+
downloads/
|
| 10 |
+
venv_py310_detox/
|
| 11 |
+
muril-base-local/
|
| 12 |
+
MIXED_MODERATION_TEST_RESULTS.md
|
| 13 |
+
pip_error.log
|
| 14 |
+
train_log.txt
|
| 15 |
+
train_trace.txt
|
| 16 |
+
setup_and_train.ps1
|
| 17 |
+
download_muril.py
|
| 18 |
+
fix_transformers.py
|
| 19 |
+
checkpoints/indicbart-base/
|
| 20 |
+
checkpoints/indicbart-base-fresh/
|
| 21 |
+
checkpoints/indicbart-detox/
|
| 22 |
+
checkpoints/indicbart-detox-from-fresh-base/
|
| 23 |
+
checkpoints/muril-toxicity-finetuned/
|
| 24 |
+
checkpoints/muril-toxicity-finetuned/checkpoint-*/
|
Dockerfile
CHANGED
|
@@ -1,20 +1,37 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
|
| 3 |
-
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
WORKDIR /app
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
COPY requirements.txt .
|
| 10 |
-
RUN pip install --upgrade pip
|
| 11 |
-
RUN pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt
|
| 12 |
|
|
|
|
| 13 |
COPY app ./app
|
| 14 |
-
COPY .env.example ./.env
|
| 15 |
-
COPY models ./models
|
| 16 |
-
COPY README.md ./README.md
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
EXPOSE 8000
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
HF_HOME=/tmp/huggingface \
|
| 7 |
+
TRANSFORMERS_CACHE=/tmp/huggingface/hub \
|
| 8 |
+
SAFECHAT_CLASSIFIER_MODEL=vineet88/safechat-muril-toxicity-finetuned \
|
| 9 |
+
SAFECHAT_DETOX_MODEL=ai4bharat/IndicBART \
|
| 10 |
+
SAFECHAT_USE_MODEL_DETOX=false
|
| 11 |
|
| 12 |
WORKDIR /app
|
| 13 |
|
| 14 |
+
# Install system dependencies
|
| 15 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
+
build-essential \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# Copy requirements first (Docker cache optimization)
|
| 20 |
COPY requirements.txt .
|
| 21 |
+
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
|
|
|
| 22 |
|
| 23 |
+
# Copy only the runtime payload used by the Space.
|
| 24 |
COPY app ./app
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
# Create directories
|
| 27 |
+
RUN mkdir -p /app/checkpoints /app/models /tmp/huggingface
|
| 28 |
+
|
| 29 |
+
# Expose port
|
| 30 |
EXPOSE 8000
|
| 31 |
|
| 32 |
+
# Health check
|
| 33 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
|
| 34 |
+
CMD python -c "import httpx; r = httpx.get('http://localhost:8000/api/v1/health'); exit(0 if r.status_code == 200 else 1)"
|
| 35 |
+
|
| 36 |
+
# Run with uvicorn (1 worker — models are loaded per worker)
|
| 37 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
README.md
CHANGED
|
@@ -1,57 +1,35 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🛡️
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 8000
|
| 8 |
pinned: false
|
| 9 |
-
license: mit
|
| 10 |
-
short_description: Standalone multilingual safety inference API.
|
| 11 |
---
|
| 12 |
|
| 13 |
-
#
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
-
|
| 20 |
-
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
|
| 26 |
-
##
|
| 27 |
|
| 28 |
-
``
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
pip install -r requirements.txt
|
| 32 |
-
Copy-Item .env.example .env
|
| 33 |
-
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
| 34 |
-
```
|
| 35 |
|
| 36 |
-
##
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
``
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
- `GET /api/v1/health`
|
| 45 |
-
- `POST /api/v1/moderate`
|
| 46 |
-
|
| 47 |
-
## Deploy to Hugging Face Spaces
|
| 48 |
-
|
| 49 |
-
Create a Docker Space and upload this folder:
|
| 50 |
-
|
| 51 |
-
```powershell
|
| 52 |
-
.\.venv\Scripts\python.exe standalone-ml-service\deploy_to_hf_space.py --repo-id your-username/your-space-name --token hf_xxx
|
| 53 |
-
```
|
| 54 |
-
|
| 55 |
-
Optional flags:
|
| 56 |
-
|
| 57 |
-
- `--private`
|
|
|
|
| 1 |
---
|
| 2 |
+
title: SafeChat ML Service
|
|
|
|
| 3 |
colorFrom: blue
|
| 4 |
+
colorTo: gray
|
| 5 |
sdk: docker
|
| 6 |
app_port: 8000
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# SafeChat ML Service
|
| 11 |
|
| 12 |
+
FastAPI-based toxicity classification and detoxification service for English, Hindi, and Hinglish.
|
| 13 |
|
| 14 |
+
## What This Space Exposes
|
| 15 |
|
| 16 |
+
- `GET /` service metadata
|
| 17 |
+
- `GET /docs` interactive API docs
|
| 18 |
+
- `POST /api/v1/moderate` toxicity classification with polite suggestions
|
| 19 |
+
- `POST /api/v1/moderate/batch` batch moderation
|
| 20 |
+
- `POST /api/v1/detoxify` detoxification-only endpoint
|
| 21 |
+
- `GET /api/v1/health` and `GET /api/v1/ready` health endpoints
|
| 22 |
|
| 23 |
+
## Model Setup
|
| 24 |
|
| 25 |
+
- Toxicity classifier: `vineet88/safechat-muril-toxicity-finetuned`
|
| 26 |
+
- Detoxifier: template fallback enabled by default in the Space for faster startup on CPU
|
| 27 |
+
- Optional detox model: `ai4bharat/IndicBART` can still be enabled by setting `SAFECHAT_USE_MODEL_DETOX=true`
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
## Notes
|
| 30 |
|
| 31 |
+
- The Space is packaged as a Docker Space.
|
| 32 |
+
- The container listens on port `8000`.
|
| 33 |
+
- The root endpoint returns JSON. API docs are available at `/docs`.
|
| 34 |
+
- The Space pulls the fine-tuned MuRIL checkpoint from a separate Hugging Face model repo at runtime.
|
| 35 |
+
- The default Space configuration favors faster boot over heavy seq2seq model loading.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
# SafeChat ML Service
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
app/api/dependencies.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — FastAPI Dependencies
|
| 3 |
+
|
| 4 |
+
Shared dependencies injected into API route handlers.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import Depends, HTTPException
|
| 8 |
+
|
| 9 |
+
from app.models.model_manager import model_manager
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def require_models_ready():
|
| 13 |
+
"""
|
| 14 |
+
Dependency that ensures ML models are loaded before processing requests.
|
| 15 |
+
Raises 503 if models aren't ready yet.
|
| 16 |
+
"""
|
| 17 |
+
if not model_manager.is_ready:
|
| 18 |
+
raise HTTPException(
|
| 19 |
+
status_code=503,
|
| 20 |
+
detail="ML models are still loading. Please try again in a moment.",
|
| 21 |
+
)
|
| 22 |
+
return model_manager
|
app/api/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API Routes package
|
app/api/routes/detoxify.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Detoxify API Route
|
| 3 |
+
|
| 4 |
+
POST /api/v1/detoxify — Generate a polite alternative for toxic text
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, HTTPException
|
| 8 |
+
|
| 9 |
+
from app.models.model_manager import model_manager
|
| 10 |
+
from app.schemas.moderation import DetoxifyRequest, DetoxifyResponse
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/api/v1", tags=["Detoxification"])
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.post("/detoxify", response_model=DetoxifyResponse)
|
| 16 |
+
async def detoxify_text(request: DetoxifyRequest):
|
| 17 |
+
"""
|
| 18 |
+
Generate a polite, non-toxic alternative for the given text.
|
| 19 |
+
|
| 20 |
+
Supports English, Hindi, and Hinglish (code-mixed) text.
|
| 21 |
+
Uses template-based suggestions (Phase 1) or model-based
|
| 22 |
+
generation (Phase 2, when enabled).
|
| 23 |
+
"""
|
| 24 |
+
if not model_manager.is_ready:
|
| 25 |
+
raise HTTPException(status_code=503, detail="Models not loaded yet.")
|
| 26 |
+
|
| 27 |
+
detoxifier = model_manager.detoxifier
|
| 28 |
+
if not detoxifier:
|
| 29 |
+
raise HTTPException(status_code=503, detail="Detoxifier not available.")
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
# First classify to know which category of toxicity
|
| 33 |
+
classification = model_manager.classifier.predict(request.text)
|
| 34 |
+
|
| 35 |
+
result = detoxifier.detoxify(
|
| 36 |
+
text=request.text,
|
| 37 |
+
toxicity_categories=classification["categories"],
|
| 38 |
+
target_language=request.target_language,
|
| 39 |
+
preserve_intent=request.preserve_intent,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
return DetoxifyResponse(**result)
|
| 43 |
+
|
| 44 |
+
except Exception as e:
|
| 45 |
+
raise HTTPException(status_code=500, detail=f"Detoxification failed: {str(e)}")
|
app/api/routes/feedback.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Feedback API Route
|
| 3 |
+
|
| 4 |
+
POST /api/v1/feedback — Submit moderator feedback
|
| 5 |
+
GET /api/v1/feedback/stats — Get feedback statistics
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, HTTPException
|
| 9 |
+
|
| 10 |
+
from app.schemas.feedback import FeedbackRequest, FeedbackResponse, FeedbackStats
|
| 11 |
+
from app.services.feedback_service import feedback_service
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/v1", tags=["Feedback & Continuous Learning"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("/feedback", response_model=FeedbackResponse)
|
| 17 |
+
async def submit_feedback(request: FeedbackRequest):
|
| 18 |
+
"""
|
| 19 |
+
Submit moderator feedback on a moderation decision.
|
| 20 |
+
|
| 21 |
+
This feedback is used for:
|
| 22 |
+
1. Tracking model accuracy over time
|
| 23 |
+
2. Collecting training data for model retraining
|
| 24 |
+
3. Triggering automatic retraining when threshold is reached
|
| 25 |
+
"""
|
| 26 |
+
try:
|
| 27 |
+
result = await feedback_service.submit_feedback(request.model_dump())
|
| 28 |
+
return FeedbackResponse(**result)
|
| 29 |
+
except Exception as e:
|
| 30 |
+
raise HTTPException(status_code=500, detail=f"Failed to submit feedback: {str(e)}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get("/feedback/stats", response_model=FeedbackStats)
|
| 34 |
+
async def get_feedback_stats():
|
| 35 |
+
"""
|
| 36 |
+
Get feedback statistics including model accuracy and retraining progress.
|
| 37 |
+
|
| 38 |
+
Shows:
|
| 39 |
+
- Total feedback count
|
| 40 |
+
- Model accuracy (correct / total)
|
| 41 |
+
- Progress toward next retraining trigger
|
| 42 |
+
"""
|
| 43 |
+
try:
|
| 44 |
+
stats = await feedback_service.get_stats()
|
| 45 |
+
return FeedbackStats(**stats)
|
| 46 |
+
except Exception as e:
|
| 47 |
+
raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
|
app/api/routes/health.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Health Check API Route
|
| 3 |
+
|
| 4 |
+
GET /api/v1/health — Service health with model status and GPU info
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from fastapi import APIRouter
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.models.model_manager import model_manager
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/v1", tags=["Health"])
|
| 14 |
+
|
| 15 |
+
_start_time = time.time()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.get("/health")
|
| 19 |
+
async def health_check():
|
| 20 |
+
"""
|
| 21 |
+
Comprehensive health check including model status and GPU metrics.
|
| 22 |
+
|
| 23 |
+
Used by:
|
| 24 |
+
- Spring Boot backend to verify ML service availability
|
| 25 |
+
- Docker health checks
|
| 26 |
+
- Monitoring dashboards
|
| 27 |
+
"""
|
| 28 |
+
health = model_manager.get_health()
|
| 29 |
+
|
| 30 |
+
return {
|
| 31 |
+
**health,
|
| 32 |
+
"service": settings.APP_NAME,
|
| 33 |
+
"version": settings.APP_VERSION,
|
| 34 |
+
"uptime_seconds": int(time.time() - _start_time),
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/ready")
|
| 39 |
+
async def readiness_check():
|
| 40 |
+
"""
|
| 41 |
+
Readiness probe — returns 200 only when models are loaded and ready.
|
| 42 |
+
Used by Kubernetes / Docker for routing traffic.
|
| 43 |
+
"""
|
| 44 |
+
if model_manager.is_ready:
|
| 45 |
+
return {"ready": True}
|
| 46 |
+
return {"ready": False, "detail": "Models still loading..."}
|
app/api/routes/moderation.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Moderation API Route
|
| 3 |
+
|
| 4 |
+
POST /api/v1/moderate — Moderate a single message
|
| 5 |
+
POST /api/v1/moderate/batch — Moderate multiple messages
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
from fastapi import APIRouter, HTTPException
|
| 10 |
+
|
| 11 |
+
from app.models.model_manager import model_manager
|
| 12 |
+
from app.schemas.moderation import (
|
| 13 |
+
ModerationRequest,
|
| 14 |
+
ModerationResponse,
|
| 15 |
+
BatchModerationRequest,
|
| 16 |
+
BatchModerationResponse,
|
| 17 |
+
)
|
| 18 |
+
from app.services.moderation_service import moderation_service
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/v1", tags=["Moderation"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post("/moderate", response_model=ModerationResponse)
|
| 24 |
+
async def moderate_message(request: ModerationRequest):
|
| 25 |
+
"""
|
| 26 |
+
Classify a message for toxicity and suggest a polite alternative.
|
| 27 |
+
|
| 28 |
+
Returns toxicity scores across 6 categories, overall severity,
|
| 29 |
+
detected language, and a suggested rephrasing if the message is toxic.
|
| 30 |
+
"""
|
| 31 |
+
if not model_manager.is_ready:
|
| 32 |
+
raise HTTPException(status_code=503, detail="Models not loaded yet. Please wait.")
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
result = await moderation_service.moderate(
|
| 36 |
+
text=request.text,
|
| 37 |
+
context=request.context,
|
| 38 |
+
channel_id=request.channel_id,
|
| 39 |
+
user_id=request.user_id,
|
| 40 |
+
)
|
| 41 |
+
return result
|
| 42 |
+
except Exception as e:
|
| 43 |
+
raise HTTPException(status_code=500, detail=f"Moderation failed: {str(e)}")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.post("/moderate/batch", response_model=BatchModerationResponse)
|
| 47 |
+
async def moderate_batch(request: BatchModerationRequest):
|
| 48 |
+
"""
|
| 49 |
+
Moderate multiple messages in a single request.
|
| 50 |
+
Max 50 messages per batch.
|
| 51 |
+
"""
|
| 52 |
+
if not model_manager.is_ready:
|
| 53 |
+
raise HTTPException(status_code=503, detail="Models not loaded yet. Please wait.")
|
| 54 |
+
|
| 55 |
+
start_time = time.perf_counter()
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
results = await moderation_service.moderate_batch(
|
| 59 |
+
texts=request.texts,
|
| 60 |
+
channel_id=request.channel_id,
|
| 61 |
+
user_id=request.user_id,
|
| 62 |
+
)
|
| 63 |
+
total_time = int((time.perf_counter() - start_time) * 1000)
|
| 64 |
+
|
| 65 |
+
return BatchModerationResponse(
|
| 66 |
+
results=results,
|
| 67 |
+
total_inference_time_ms=total_time,
|
| 68 |
+
)
|
| 69 |
+
except Exception as e:
|
| 70 |
+
raise HTTPException(status_code=500, detail=f"Batch moderation failed: {str(e)}")
|
app/config.py
CHANGED
|
@@ -1,37 +1,86 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
| 2 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class Settings(BaseSettings):
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
return Settings()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat ML Service — Configuration
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
from pathlib import Path
|
| 6 |
+
from pydantic_settings import BaseSettings
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 11 |
+
DEFAULT_CHECKPOINT_DIR = BASE_DIR / "checkpoints"
|
| 12 |
+
DEFAULT_CLASSIFIER_PATH = DEFAULT_CHECKPOINT_DIR / "muril-toxicity-finetuned"
|
| 13 |
+
DEFAULT_DETOX_BASE_FRESH_PATH = DEFAULT_CHECKPOINT_DIR / "indicbart-base-fresh"
|
| 14 |
+
DEFAULT_DETOX_BASE_PATH = DEFAULT_CHECKPOINT_DIR / "indicbart-base"
|
| 15 |
+
DEFAULT_DETOX_FINAL_PATH = DEFAULT_CHECKPOINT_DIR / "indicbart-detox"
|
| 16 |
+
DEFAULT_DETOX_CHECKPOINT_PATH = DEFAULT_DETOX_FINAL_PATH / "checkpoint-1000"
|
| 17 |
|
|
|
|
| 18 |
|
| 19 |
+
def _prefer_local_checkpoint(local_paths: list[Path], fallback: str) -> str:
|
| 20 |
+
"""Use the first available local checkpoint, otherwise fall back."""
|
| 21 |
+
for local_path in local_paths:
|
| 22 |
+
has_config = (local_path / "config.json").exists()
|
| 23 |
+
has_weights = (local_path / "model.safetensors").exists() or (local_path / "pytorch_model.bin").exists()
|
| 24 |
+
if has_config and has_weights:
|
| 25 |
+
return str(local_path)
|
| 26 |
+
return fallback
|
| 27 |
|
| 28 |
|
| 29 |
class Settings(BaseSettings):
|
| 30 |
+
"""Application settings with environment variable support."""
|
| 31 |
+
|
| 32 |
+
# ── App ──────────────────────────────────────────────
|
| 33 |
+
APP_NAME: str = "SafeChat ML Service (MuRIL & IndicBART)"
|
| 34 |
+
APP_VERSION: str = "2.0.0"
|
| 35 |
+
DEBUG: bool = True
|
| 36 |
+
HOST: str = "0.0.0.0"
|
| 37 |
+
PORT: int = 8000
|
| 38 |
|
| 39 |
+
# ── Device ───────────────────────────────────────────
|
| 40 |
+
DEVICE: str = "cuda" if torch.cuda.is_available() else "cpu"
|
| 41 |
+
|
| 42 |
+
# ── Model Configuration ──────────────────────────────
|
| 43 |
+
# Prefer saved fine-tuned checkpoints when available.
|
| 44 |
+
CLASSIFIER_MODEL: str = _prefer_local_checkpoint(
|
| 45 |
+
[DEFAULT_CLASSIFIER_PATH],
|
| 46 |
+
"google/muril-base-cased",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Use the original downloaded IndicBART model for prompt-based detoxification.
|
| 50 |
+
DETOX_MODEL: str = _prefer_local_checkpoint(
|
| 51 |
+
[
|
| 52 |
+
DEFAULT_DETOX_BASE_FRESH_PATH,
|
| 53 |
+
DEFAULT_DETOX_BASE_PATH,
|
| 54 |
+
DEFAULT_DETOX_FINAL_PATH,
|
| 55 |
+
DEFAULT_DETOX_CHECKPOINT_PATH,
|
| 56 |
+
],
|
| 57 |
+
"ai4bharat/IndicBART",
|
| 58 |
)
|
| 59 |
+
DETOX_MAX_LENGTH: int = 128
|
| 60 |
+
DETOX_NUM_BEAMS: int = 4
|
| 61 |
+
USE_MODEL_DETOX: bool = True # Enforce Model usage
|
| 62 |
+
|
| 63 |
+
# ── Inference Settings ───────────────────────────────
|
| 64 |
+
MAX_SEQ_LENGTH: int = 256
|
| 65 |
+
BATCH_SIZE: int = 8
|
| 66 |
+
|
| 67 |
+
# ── Severity Thresholds ──────────────────────────────
|
| 68 |
+
THRESHOLD_SAFE: float = 0.30
|
| 69 |
+
THRESHOLD_SAFE_HINGLISH: float = 0.18
|
| 70 |
+
THRESHOLD_LOW: float = 0.55
|
| 71 |
+
THRESHOLD_MEDIUM: float = 0.75
|
| 72 |
+
|
| 73 |
+
# ── MongoDB & Continuous Learning ──────────────────────
|
| 74 |
+
MONGODB_URL: str = "mongodb://localhost:27017"
|
| 75 |
+
MONGODB_DB: str = "safechat"
|
| 76 |
+
FEEDBACK_THRESHOLD_FOR_RETRAIN: int = 500
|
| 77 |
+
MODEL_CHECKPOINT_DIR: str = str(DEFAULT_CHECKPOINT_DIR)
|
| 78 |
|
| 79 |
+
class Config:
|
| 80 |
+
env_file = ".env"
|
| 81 |
+
env_prefix = "SAFECHAT_"
|
| 82 |
+
case_sensitive = True
|
| 83 |
|
| 84 |
|
| 85 |
+
# Singleton settings instance
|
| 86 |
+
settings = Settings()
|
|
|
app/main.py
CHANGED
|
@@ -1,97 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from contextlib import asynccontextmanager
|
| 2 |
-
from time import perf_counter
|
| 3 |
|
| 4 |
-
from fastapi import
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
from app.config import get_settings
|
| 8 |
-
from app.language import HeuristicLanguageDetector
|
| 9 |
-
from app.model import TransformerModerationModel
|
| 10 |
-
from app.normalization import TextNormalizer
|
| 11 |
-
from app.schemas import HealthResponse, ModerateRequest, ModerateResponse
|
| 12 |
|
|
|
|
| 13 |
|
| 14 |
@asynccontextmanager
|
| 15 |
async def lifespan(app: FastAPI):
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat ML Service — FastAPI Application
|
| 3 |
+
|
| 4 |
+
Entry point for the ML inference service.
|
| 5 |
+
Handles toxicity classification, detoxification, and feedback collection.
|
| 6 |
+
|
| 7 |
+
Run with:
|
| 8 |
+
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
| 9 |
+
|
| 10 |
+
Or for production:
|
| 11 |
+
gunicorn app.main:app -w 1 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
|
| 12 |
+
(Use 1 worker since models are loaded in-memory per worker)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
from contextlib import asynccontextmanager
|
|
|
|
| 16 |
|
| 17 |
+
from fastapi import FastAPI
|
| 18 |
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
+
from loguru import logger
|
| 20 |
+
|
| 21 |
+
from app.config import settings
|
| 22 |
+
from app.models.model_manager import model_manager
|
| 23 |
+
|
| 24 |
+
# Import route modules
|
| 25 |
+
from app.api.routes import moderation, detoxify, health, feedback
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
# ── Application Lifespan ───────────────────────────────────────────────
|
| 29 |
|
| 30 |
@asynccontextmanager
|
| 31 |
async def lifespan(app: FastAPI):
|
| 32 |
+
"""
|
| 33 |
+
Startup: Load ML models into GPU memory.
|
| 34 |
+
Shutdown: Clean up resources.
|
| 35 |
+
"""
|
| 36 |
+
# ── STARTUP ────────────────────────────────────────
|
| 37 |
+
logger.info("Starting SafeChat ML Service...")
|
| 38 |
+
await model_manager.initialize()
|
| 39 |
+
logger.success(f"SafeChat ML Service ready on {settings.HOST}:{settings.PORT}")
|
| 40 |
+
|
| 41 |
+
yield # Application runs here
|
| 42 |
+
|
| 43 |
+
# ── SHUTDOWN ───────────────────────────────────────
|
| 44 |
+
logger.info("Shutting down SafeChat ML Service...")
|
| 45 |
+
# GPU memory cleanup
|
| 46 |
+
import torch
|
| 47 |
+
if torch.cuda.is_available():
|
| 48 |
+
torch.cuda.empty_cache()
|
| 49 |
+
logger.info("Cleanup complete. Goodbye!")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ── Create FastAPI Application ─────────────────────────────────────────
|
| 53 |
+
|
| 54 |
+
app = FastAPI(
|
| 55 |
+
title=settings.APP_NAME,
|
| 56 |
+
version=settings.APP_VERSION,
|
| 57 |
+
description=(
|
| 58 |
+
"Real-time toxicity classification and detoxification service. "
|
| 59 |
+
"Supports English, Hindi, Hinglish (code-mixed), and Indian languages. "
|
| 60 |
+
"Features a language-aware ensemble classifier with continuous learning."
|
| 61 |
+
),
|
| 62 |
+
docs_url="/docs",
|
| 63 |
+
redoc_url="/redoc",
|
| 64 |
+
lifespan=lifespan,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ── CORS Middleware ────────────────────────────────────────────────────
|
| 69 |
+
# Allow React frontend and Spring Boot backend to call this service
|
| 70 |
+
|
| 71 |
+
app.add_middleware(
|
| 72 |
+
CORSMiddleware,
|
| 73 |
+
allow_origins=[
|
| 74 |
+
"http://localhost:3000", # React dev server
|
| 75 |
+
"http://localhost:5173", # Vite dev server
|
| 76 |
+
"http://localhost:8080", # Spring Boot
|
| 77 |
+
"http://127.0.0.1:3000",
|
| 78 |
+
"http://127.0.0.1:5173",
|
| 79 |
+
"http://127.0.0.1:8080",
|
| 80 |
+
],
|
| 81 |
+
allow_credentials=True,
|
| 82 |
+
allow_methods=["*"],
|
| 83 |
+
allow_headers=["*"],
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ── Register Routes ───────────────────────────────────────────────────
|
| 88 |
+
|
| 89 |
+
app.include_router(moderation.router)
|
| 90 |
+
app.include_router(detoxify.router)
|
| 91 |
+
app.include_router(health.router)
|
| 92 |
+
app.include_router(feedback.router)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ── Root Endpoint ─────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
@app.get("/", tags=["Root"])
|
| 98 |
+
async def root():
|
| 99 |
+
"""Service info and links to documentation."""
|
| 100 |
+
return {
|
| 101 |
+
"service": settings.APP_NAME,
|
| 102 |
+
"version": settings.APP_VERSION,
|
| 103 |
+
"docs": "/docs",
|
| 104 |
+
"health": "/api/v1/health",
|
| 105 |
+
"endpoints": {
|
| 106 |
+
"moderate": "POST /api/v1/moderate",
|
| 107 |
+
"moderate_batch": "POST /api/v1/moderate/batch",
|
| 108 |
+
"detoxify": "POST /api/v1/detoxify",
|
| 109 |
+
"feedback": "POST /api/v1/feedback",
|
| 110 |
+
"feedback_stats": "GET /api/v1/feedback/stats",
|
| 111 |
+
"health": "GET /api/v1/health",
|
| 112 |
+
"ready": "GET /api/v1/ready",
|
| 113 |
+
},
|
| 114 |
+
}
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Models package
|
app/models/detoxifier.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Text Detoxifier (IndicBART Generation)
|
| 3 |
+
|
| 4 |
+
Converts toxic Hindi/Hinglish/English sentences to polite versions.
|
| 5 |
+
Uses the original downloaded IndicBART model with prompt-based generation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from typing import Dict, List, Optional
|
| 10 |
+
from loguru import logger
|
| 11 |
+
import torch
|
| 12 |
+
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
| 13 |
+
|
| 14 |
+
from app.config import settings
|
| 15 |
+
from app.utils.preprocessing import detect_language
|
| 16 |
+
|
| 17 |
+
LANGUAGE_TAGS = {
|
| 18 |
+
"en": "<2en>",
|
| 19 |
+
"hi": "<2hi>",
|
| 20 |
+
"hi-en": "<2en>",
|
| 21 |
+
"indic-en": "<2en>",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
POLITE_FALLBACKS = {
|
| 25 |
+
"en": "Could you please say that more politely and respectfully?",
|
| 26 |
+
"hi": "कृपया यही बात थोड़े विनम्र और सम्मानजनक तरीके से कहें।",
|
| 27 |
+
"hi-en": "Please isi baat ko thoda politely aur respectfully bolo.",
|
| 28 |
+
"indic-en": "Please say this more politely and respectfully.",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Detoxifier:
|
| 33 |
+
def __init__(self):
|
| 34 |
+
self._model = None
|
| 35 |
+
self._tokenizer = None
|
| 36 |
+
self._model_loaded = False
|
| 37 |
+
logger.info("Detoxifier initialized (IndicBART mode).")
|
| 38 |
+
|
| 39 |
+
def load_model(self) -> None:
|
| 40 |
+
if not settings.USE_MODEL_DETOX:
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
logger.info(f"Loading IndicBART detox model: {settings.DETOX_MODEL}...")
|
| 45 |
+
self._tokenizer = AutoTokenizer.from_pretrained(settings.DETOX_MODEL, use_fast=False)
|
| 46 |
+
self._model = AutoModelForSeq2SeqLM.from_pretrained(settings.DETOX_MODEL)
|
| 47 |
+
self._model.to(settings.DEVICE)
|
| 48 |
+
self._model.eval()
|
| 49 |
+
self._model_loaded = True
|
| 50 |
+
logger.success(f"IndicBART Detox model loaded successfully on {settings.DEVICE}.")
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.warning(f"Failed to load IndicBART: {e}")
|
| 53 |
+
self._model_loaded = False
|
| 54 |
+
|
| 55 |
+
def detoxify(
|
| 56 |
+
self,
|
| 57 |
+
text: str,
|
| 58 |
+
toxicity_categories: Optional[Dict[str, float]] = None,
|
| 59 |
+
target_language: Optional[str] = None,
|
| 60 |
+
preserve_intent: bool = True,
|
| 61 |
+
) -> Dict:
|
| 62 |
+
lang = self._normalize_language(target_language or detect_language(text))
|
| 63 |
+
dominant_category = self._dominant_category(toxicity_categories)
|
| 64 |
+
|
| 65 |
+
if toxicity_categories and max(toxicity_categories.values(), default=0.0) < self._safe_threshold_for_language(lang):
|
| 66 |
+
return {
|
| 67 |
+
"original": text,
|
| 68 |
+
"detoxified": text,
|
| 69 |
+
"suggestions": [text],
|
| 70 |
+
"method": "passthrough",
|
| 71 |
+
"language": lang,
|
| 72 |
+
"confidence": 1.0,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
generated = None
|
| 76 |
+
if self._model_loaded and settings.USE_MODEL_DETOX:
|
| 77 |
+
generated = self._model_detoxify(text, lang)
|
| 78 |
+
|
| 79 |
+
suggestions = self._build_suggestions(
|
| 80 |
+
original_text=text,
|
| 81 |
+
lang=lang,
|
| 82 |
+
dominant_category=dominant_category,
|
| 83 |
+
generated=generated,
|
| 84 |
+
preserve_intent=preserve_intent,
|
| 85 |
+
)
|
| 86 |
+
primary = suggestions[0] if suggestions else self._fallback_rewrite(lang)
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
"original": text,
|
| 90 |
+
"detoxified": primary,
|
| 91 |
+
"suggestions": suggestions,
|
| 92 |
+
"method": "indic_bart" if generated else "template",
|
| 93 |
+
"language": lang,
|
| 94 |
+
"confidence": 0.85 if generated else 0.60,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def _model_detoxify(self, text: str, lang: str) -> Optional[str]:
|
| 98 |
+
if not self._model or not self._tokenizer:
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
prompt = self._build_prompt(text, lang)
|
| 103 |
+
|
| 104 |
+
inputs = self._tokenizer(
|
| 105 |
+
prompt,
|
| 106 |
+
return_tensors="pt",
|
| 107 |
+
max_length=settings.MAX_SEQ_LENGTH,
|
| 108 |
+
truncation=True,
|
| 109 |
+
)
|
| 110 |
+
inputs = {k: v.to(settings.DEVICE) for k, v in inputs.items()}
|
| 111 |
+
|
| 112 |
+
forced_bos_token_id = self._forced_bos_token_id(lang)
|
| 113 |
+
with torch.no_grad():
|
| 114 |
+
outputs = self._model.generate(
|
| 115 |
+
**inputs,
|
| 116 |
+
max_new_tokens=settings.DETOX_MAX_LENGTH,
|
| 117 |
+
num_beams=settings.DETOX_NUM_BEAMS,
|
| 118 |
+
min_new_tokens=8,
|
| 119 |
+
no_repeat_ngram_size=3,
|
| 120 |
+
repetition_penalty=1.2,
|
| 121 |
+
length_penalty=1.0,
|
| 122 |
+
early_stopping=True,
|
| 123 |
+
forced_bos_token_id=forced_bos_token_id,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
result = self._tokenizer.decode(outputs[0], skip_special_tokens=False)
|
| 127 |
+
cleaned = self._clean_generation(result, prompt, text)
|
| 128 |
+
return cleaned
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"IndicBART detoxification failed: {e}")
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
def get_info(self) -> Dict:
|
| 135 |
+
return {
|
| 136 |
+
"mode": "indic_bart" if self._model_loaded else "fallback",
|
| 137 |
+
"model_name": settings.DETOX_MODEL if self._model_loaded else None,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
@staticmethod
|
| 141 |
+
def _normalize_language(lang: str) -> str:
|
| 142 |
+
if lang in {"hi", "hi-en", "indic-en", "en"}:
|
| 143 |
+
return lang
|
| 144 |
+
return "en"
|
| 145 |
+
|
| 146 |
+
@staticmethod
|
| 147 |
+
def _safe_threshold_for_language(lang: str) -> float:
|
| 148 |
+
if lang == "hi-en":
|
| 149 |
+
return settings.THRESHOLD_SAFE_HINGLISH
|
| 150 |
+
return settings.THRESHOLD_SAFE
|
| 151 |
+
|
| 152 |
+
def _forced_bos_token_id(self, lang: str) -> Optional[int]:
|
| 153 |
+
if not self._tokenizer:
|
| 154 |
+
return None
|
| 155 |
+
token = LANGUAGE_TAGS.get(lang, "<2en>")
|
| 156 |
+
token_id = self._tokenizer.convert_tokens_to_ids(token)
|
| 157 |
+
return token_id if isinstance(token_id, int) and token_id >= 0 else None
|
| 158 |
+
|
| 159 |
+
@staticmethod
|
| 160 |
+
def _build_prompt(text: str, lang: str) -> str:
|
| 161 |
+
if lang == "hi":
|
| 162 |
+
return (
|
| 163 |
+
"इस अपमानजनक वाक्य को विनम्र और सम्मानजनक हिंदी में दोबारा लिखो.\n"
|
| 164 |
+
f"अपमानजनक: {text}\n"
|
| 165 |
+
"विनम्र:"
|
| 166 |
+
)
|
| 167 |
+
if lang == "hi-en":
|
| 168 |
+
return (
|
| 169 |
+
"Is toxic text ko polite aur respectful Hinglish mein Roman script me rewrite karo.\n"
|
| 170 |
+
f"Toxic: {text}\n"
|
| 171 |
+
"Polite Hinglish:"
|
| 172 |
+
)
|
| 173 |
+
return (
|
| 174 |
+
"Rewrite the following toxic text into polite and respectful English.\n"
|
| 175 |
+
f"Toxic: {text}\n"
|
| 176 |
+
"Polite:"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
@staticmethod
|
| 180 |
+
def _fallback_rewrite(lang: str) -> str:
|
| 181 |
+
return POLITE_FALLBACKS.get(lang, POLITE_FALLBACKS["en"])
|
| 182 |
+
|
| 183 |
+
@staticmethod
|
| 184 |
+
def _dominant_category(toxicity_categories: Optional[Dict[str, float]]) -> str:
|
| 185 |
+
if not toxicity_categories:
|
| 186 |
+
return "toxic"
|
| 187 |
+
for label in ("identity_hate", "threat", "obscene", "insult"):
|
| 188 |
+
if toxicity_categories.get(label, 0.0) >= 0.30:
|
| 189 |
+
return label
|
| 190 |
+
return max(toxicity_categories, key=toxicity_categories.get)
|
| 191 |
+
|
| 192 |
+
def _build_suggestions(
|
| 193 |
+
self,
|
| 194 |
+
original_text: str,
|
| 195 |
+
lang: str,
|
| 196 |
+
dominant_category: str,
|
| 197 |
+
generated: Optional[str],
|
| 198 |
+
preserve_intent: bool,
|
| 199 |
+
) -> List[str]:
|
| 200 |
+
suggestions: List[str] = []
|
| 201 |
+
if generated:
|
| 202 |
+
suggestions.append(generated)
|
| 203 |
+
|
| 204 |
+
suggestions.extend(self._template_suggestions(lang, dominant_category, preserve_intent))
|
| 205 |
+
if not suggestions:
|
| 206 |
+
suggestions.append(self._fallback_rewrite(lang))
|
| 207 |
+
|
| 208 |
+
deduped: List[str] = []
|
| 209 |
+
seen = set()
|
| 210 |
+
original_normalized = original_text.strip().lower()
|
| 211 |
+
for suggestion in suggestions:
|
| 212 |
+
cleaned = suggestion.strip()
|
| 213 |
+
if not cleaned:
|
| 214 |
+
continue
|
| 215 |
+
normalized = cleaned.lower()
|
| 216 |
+
if normalized == original_normalized:
|
| 217 |
+
continue
|
| 218 |
+
if normalized in seen:
|
| 219 |
+
continue
|
| 220 |
+
seen.add(normalized)
|
| 221 |
+
deduped.append(cleaned)
|
| 222 |
+
if len(deduped) == 3:
|
| 223 |
+
break
|
| 224 |
+
|
| 225 |
+
return deduped or [self._fallback_rewrite(lang)]
|
| 226 |
+
|
| 227 |
+
def _template_suggestions(self, lang: str, dominant_category: str, preserve_intent: bool) -> List[str]:
|
| 228 |
+
category = dominant_category if dominant_category in {"insult", "obscene", "threat", "identity_hate"} else "toxic"
|
| 229 |
+
|
| 230 |
+
if lang == "hi":
|
| 231 |
+
templates = {
|
| 232 |
+
"toxic": [
|
| 233 |
+
"कृपया यही बात थोड़े विनम्र और सम्मानजनक तरीके से कहें।",
|
| 234 |
+
"अपनी बात बिना अपमानजनक भाषा के भी कही जा सकती है।",
|
| 235 |
+
"कृपया बातचीत में शिष्ट और सम्मानजनक भाषा रखें।",
|
| 236 |
+
],
|
| 237 |
+
"insult": [
|
| 238 |
+
"मैं असहमत हूँ, लेकिन कृपया सम्मानजनक भाषा रखें।",
|
| 239 |
+
"नाराज़गी जताइए, पर व्यक्तिगत अपमान मत कीजिए।",
|
| 240 |
+
"कृपया मुद्दे पर बात करें, व्यक्ति पर नहीं।",
|
| 241 |
+
],
|
| 242 |
+
"obscene": [
|
| 243 |
+
"कृपया अश्लील शब्दों के बिना अपनी बात कहें।",
|
| 244 |
+
"आप नाराज़ हो सकते हैं, लेकिन भाषा मर्यादित रखें।",
|
| 245 |
+
"अपना संदेश साफ और सम्मानजनक तरीके से रखें।",
|
| 246 |
+
],
|
| 247 |
+
"threat": [
|
| 248 |
+
"धमकी देने के बजाय शांत और स्पष्ट तरीके से अपनी बात र��ें।",
|
| 249 |
+
"मैं बहुत नाराज़ हूँ, लेकिन धमकी नहीं देना चाहता।",
|
| 250 |
+
"हिंसा या डराने वाली भाषा से बचते हुए बात करें।",
|
| 251 |
+
],
|
| 252 |
+
"identity_hate": [
|
| 253 |
+
"कृपया किसी की पहचान या समुदाय को निशाना बनाए बिना अपनी बात कहें।",
|
| 254 |
+
"असहमति व्यक्त की जा सकती है, लेकिन घृणित भाषा ठीक नहीं है।",
|
| 255 |
+
"हर व्यक्ति और समुदाय के प्रति सम्मानजनक भाषा रखें।",
|
| 256 |
+
],
|
| 257 |
+
}
|
| 258 |
+
elif lang == "hi-en":
|
| 259 |
+
templates = {
|
| 260 |
+
"toxic": [
|
| 261 |
+
"Please isi baat ko thoda politely aur respectfully bolo.",
|
| 262 |
+
"Apni baat bina gaali diye bhi clear tarah se boli ja sakti hai.",
|
| 263 |
+
"Chalo baat ko thoda calm aur respectful tareeke se rakhte hain.",
|
| 264 |
+
],
|
| 265 |
+
"insult": [
|
| 266 |
+
"Main disagree karta hoon, lekin personal insult ke bina bolunga.",
|
| 267 |
+
"Please gusse mein bhi respectfully baat karo.",
|
| 268 |
+
"Issue par bolo, insaan ko target mat karo.",
|
| 269 |
+
],
|
| 270 |
+
"obscene": [
|
| 271 |
+
"Please abusive words hata kar apni baat bolo.",
|
| 272 |
+
"Frustration dikhani hai to bhi language clean rakho.",
|
| 273 |
+
"Same point ko thoda decent aur respectful Hinglish mein bolo.",
|
| 274 |
+
],
|
| 275 |
+
"threat": [
|
| 276 |
+
"Main upset hoon, lekin dhamki nahi dunga.",
|
| 277 |
+
"Please threatening language ke bina apni baat bolo.",
|
| 278 |
+
"Chalo isko calmly resolve karte hain, darane wali language ke bina.",
|
| 279 |
+
],
|
| 280 |
+
"identity_hate": [
|
| 281 |
+
"Please kisi identity ya community ko target kiye bina baat karo.",
|
| 282 |
+
"Disagree karna theek hai, hate speech nahi.",
|
| 283 |
+
"Respectful Hinglish mein point rakho, identity-based abuse ke bina.",
|
| 284 |
+
],
|
| 285 |
+
}
|
| 286 |
+
else:
|
| 287 |
+
templates = {
|
| 288 |
+
"toxic": [
|
| 289 |
+
"Could you please say that more politely and respectfully?",
|
| 290 |
+
"You can make the same point without abusive language.",
|
| 291 |
+
"Let's keep the conversation respectful.",
|
| 292 |
+
],
|
| 293 |
+
"insult": [
|
| 294 |
+
"I disagree, but I want to say it respectfully.",
|
| 295 |
+
"Please speak respectfully even if you're upset.",
|
| 296 |
+
"Let's discuss the issue without personal insults.",
|
| 297 |
+
],
|
| 298 |
+
"obscene": [
|
| 299 |
+
"Please say this without profanity.",
|
| 300 |
+
"You can express frustration without abusive words.",
|
| 301 |
+
"Let's keep the wording clean and respectful.",
|
| 302 |
+
],
|
| 303 |
+
"threat": [
|
| 304 |
+
"I am upset, but I will not use threats.",
|
| 305 |
+
"Please address this calmly without threatening language.",
|
| 306 |
+
"Let's resolve this without violence or intimidation.",
|
| 307 |
+
],
|
| 308 |
+
"identity_hate": [
|
| 309 |
+
"Please make your point without targeting anyone's identity.",
|
| 310 |
+
"Disagreement is fine, but hateful language is not.",
|
| 311 |
+
"Let's keep this respectful toward every person and community.",
|
| 312 |
+
],
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
selected = list(templates.get(category, templates["toxic"]))
|
| 316 |
+
if not preserve_intent:
|
| 317 |
+
selected[0] = self._fallback_rewrite(lang)
|
| 318 |
+
return selected
|
| 319 |
+
|
| 320 |
+
@staticmethod
|
| 321 |
+
def _clean_generation(result: str, prompt: str, original_text: str) -> Optional[str]:
|
| 322 |
+
cleaned = result
|
| 323 |
+
cleaned = re.sub(r"</?s>", " ", cleaned, flags=re.IGNORECASE)
|
| 324 |
+
cleaned = re.sub(r"<2[a-z]+>", " ", cleaned, flags=re.IGNORECASE)
|
| 325 |
+
cleaned = cleaned.replace("[CLS]", " ").replace("[SEP]", " ").replace("<pad>", " ")
|
| 326 |
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
| 327 |
+
|
| 328 |
+
for marker in ("Polite Hinglish:", "Polite:", "विनम्र:", "अपमानजनक:", "Toxic:"):
|
| 329 |
+
if marker in cleaned:
|
| 330 |
+
cleaned = cleaned.split(marker)[-1].strip()
|
| 331 |
+
|
| 332 |
+
if not cleaned:
|
| 333 |
+
return None
|
| 334 |
+
|
| 335 |
+
# Reject degenerate outputs that mostly echo the prompt or collapse into punctuation.
|
| 336 |
+
lowered = cleaned.lower()
|
| 337 |
+
if len(cleaned) < 4:
|
| 338 |
+
return None
|
| 339 |
+
if re.fullmatch(r"[\W_]+", cleaned):
|
| 340 |
+
return None
|
| 341 |
+
if any(
|
| 342 |
+
token in lowered
|
| 343 |
+
for token in (
|
| 344 |
+
"rewrite the following",
|
| 345 |
+
"polite hinglish",
|
| 346 |
+
"toxic:",
|
| 347 |
+
"tox",
|
| 348 |
+
"polite",
|
| 349 |
+
"rewrite",
|
| 350 |
+
"अपमानजनक",
|
| 351 |
+
)
|
| 352 |
+
):
|
| 353 |
+
return None
|
| 354 |
+
if lowered == original_text.lower().strip():
|
| 355 |
+
return None
|
| 356 |
+
if re.search(r"\b(\w+)(?:\s+\1){2,}\b", lowered):
|
| 357 |
+
return None
|
| 358 |
+
|
| 359 |
+
return cleaned
|
app/models/model_manager.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Model Manager
|
| 3 |
+
Cenrtalized lifecycle manager for ML models.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, Optional
|
| 7 |
+
import torch
|
| 8 |
+
from loguru import logger
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.models.toxicity_classifier import ToxicityClassifier
|
| 12 |
+
from app.models.detoxifier import Detoxifier
|
| 13 |
+
|
| 14 |
+
class ModelManager:
|
| 15 |
+
"""Singleton-style manager for Mutli-label Classification and Seq2Seq Detoxification."""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.classifier: Optional[ToxicityClassifier] = None
|
| 19 |
+
self.detoxifier: Optional[Detoxifier] = None
|
| 20 |
+
self._initialized = False
|
| 21 |
+
|
| 22 |
+
async def initialize(self) -> None:
|
| 23 |
+
"""Load all models. Called once during FastAPI startup."""
|
| 24 |
+
logger.info("=" * 60)
|
| 25 |
+
logger.info(" SafeChat Model Manager — Initializing (MuRIL & IndicBART)")
|
| 26 |
+
logger.info("=" * 60)
|
| 27 |
+
self._log_hardware_info()
|
| 28 |
+
|
| 29 |
+
# Load MuRIL
|
| 30 |
+
try:
|
| 31 |
+
self.classifier = ToxicityClassifier(
|
| 32 |
+
model_name=settings.CLASSIFIER_MODEL,
|
| 33 |
+
device=settings.DEVICE,
|
| 34 |
+
)
|
| 35 |
+
self.classifier.load()
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.error(f"Failed to load toxicity classifier: {e}")
|
| 38 |
+
raise RuntimeError(f"Classifier initialization failed: {e}")
|
| 39 |
+
|
| 40 |
+
# Load IndicBART
|
| 41 |
+
try:
|
| 42 |
+
self.detoxifier = Detoxifier()
|
| 43 |
+
self.detoxifier.load_model()
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.warning(f"Detoxifier model loading failed: {e}. Template fallback will be used.")
|
| 46 |
+
|
| 47 |
+
self._initialized = True
|
| 48 |
+
logger.success("All models initialized successfully!")
|
| 49 |
+
logger.info("=" * 60)
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def is_ready(self) -> bool:
|
| 53 |
+
return self._initialized and self.classifier is not None and self.classifier.is_loaded
|
| 54 |
+
|
| 55 |
+
def get_health(self) -> Dict:
|
| 56 |
+
return {
|
| 57 |
+
"status": "healthy" if self.is_ready else "degraded",
|
| 58 |
+
"models": {
|
| 59 |
+
"toxicity_classifier": self.classifier.get_info() if self.classifier else {"loaded": False},
|
| 60 |
+
"detoxifier": self.detoxifier.get_info() if self.detoxifier else {"loaded": False},
|
| 61 |
+
},
|
| 62 |
+
"device": settings.DEVICE,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async def swap_classifier(self, new_model_path: str) -> bool:
|
| 66 |
+
"""Hot-swap the toxicity classifier."""
|
| 67 |
+
logger.info(f"Hot-swapping classifier to: {new_model_path}")
|
| 68 |
+
try:
|
| 69 |
+
new_classifier = ToxicityClassifier(model_name=new_model_path, device=settings.DEVICE)
|
| 70 |
+
new_classifier.load()
|
| 71 |
+
old_classifier = self.classifier
|
| 72 |
+
self.classifier = new_classifier
|
| 73 |
+
del old_classifier
|
| 74 |
+
if settings.DEVICE == "cuda":
|
| 75 |
+
torch.cuda.empty_cache()
|
| 76 |
+
return True
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.error(f"Classifier hot-swap failed: {e}.")
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
@staticmethod
|
| 82 |
+
def _log_hardware_info():
|
| 83 |
+
if torch.cuda.is_available():
|
| 84 |
+
logger.info(f"GPU: {torch.cuda.get_device_name(0)}")
|
| 85 |
+
else:
|
| 86 |
+
logger.warning("No GPU detected. Running on CPU (slower inference).")
|
| 87 |
+
logger.info(f"Selected device: {settings.DEVICE}")
|
| 88 |
+
|
| 89 |
+
model_manager = ModelManager()
|
app/models/toxicity_classifier.py
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Toxicity Classifier (Single MuRIL Model)
|
| 3 |
+
|
| 4 |
+
Architecture:
|
| 5 |
+
┌─────────┐
|
| 6 |
+
│ Input │ (Hindi, English, Hinglish)
|
| 7 |
+
└────┬────┘
|
| 8 |
+
│
|
| 9 |
+
▼
|
| 10 |
+
┌───────────────────────┐
|
| 11 |
+
│ google/muril-base │ (SequenceClassifier)
|
| 12 |
+
└───────┬───────────────┘
|
| 13 |
+
│ Output Logits
|
| 14 |
+
▼
|
| 15 |
+
┌───────────────────────┐
|
| 16 |
+
│ Sigmoid Activation │
|
| 17 |
+
└───────┬───────────────┘
|
| 18 |
+
▼
|
| 19 |
+
┌───────────────────────┐
|
| 20 |
+
│ Labels & Severity │
|
| 21 |
+
└───────────────────────┘
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import time
|
| 25 |
+
import re
|
| 26 |
+
from typing import Dict, Optional
|
| 27 |
+
import torch
|
| 28 |
+
import torch.nn.functional as F
|
| 29 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 30 |
+
from loguru import logger
|
| 31 |
+
|
| 32 |
+
from app.config import settings
|
| 33 |
+
from app.utils.preprocessing import clean_text, detect_language, normalize_for_toxicity
|
| 34 |
+
|
| 35 |
+
# Labels as defined in our training data
|
| 36 |
+
LABELS = ["toxic", "severe_toxic", "obscene", "threat", "insult", "identity_hate"]
|
| 37 |
+
|
| 38 |
+
LOW_INSULT_FLOORS = {"toxic": 0.24, "insult": 0.21}
|
| 39 |
+
MEDIUM_INSULT_FLOORS = {"toxic": 0.38, "insult": 0.34}
|
| 40 |
+
STRONG_INSULT_FLOORS = {"toxic": 0.44, "insult": 0.38}
|
| 41 |
+
OBSCENE_FLOORS = {"toxic": 0.68, "obscene": 0.62, "insult": 0.56}
|
| 42 |
+
SEVERE_OBSCENE_FLOORS = {"toxic": 0.74, "obscene": 0.68, "insult": 0.60}
|
| 43 |
+
IDENTITY_SLUR_FLOORS = {"toxic": 0.44, "insult": 0.38, "identity_hate": 0.34}
|
| 44 |
+
|
| 45 |
+
HINGLISH_LOW_INSULT_WORDS = (
|
| 46 |
+
"badir",
|
| 47 |
+
"badirchand",
|
| 48 |
+
"baklol",
|
| 49 |
+
"baklund",
|
| 50 |
+
"bakwas",
|
| 51 |
+
"bakwaas",
|
| 52 |
+
"fatu",
|
| 53 |
+
"fattu",
|
| 54 |
+
"ghatiya",
|
| 55 |
+
"gawar",
|
| 56 |
+
"jahil",
|
| 57 |
+
"jhandu",
|
| 58 |
+
"jhant",
|
| 59 |
+
"jhantu",
|
| 60 |
+
"jhaantu",
|
| 61 |
+
"nalayak",
|
| 62 |
+
"nikamma",
|
| 63 |
+
"pagal",
|
| 64 |
+
"paagal",
|
| 65 |
+
"sala",
|
| 66 |
+
"saala",
|
| 67 |
+
"saale",
|
| 68 |
+
"sali",
|
| 69 |
+
"saali",
|
| 70 |
+
"ullu",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
HINGLISH_MEDIUM_INSULT_WORDS = (
|
| 74 |
+
"bastard",
|
| 75 |
+
"bewakoof",
|
| 76 |
+
"bewaqoof",
|
| 77 |
+
"bewakuf",
|
| 78 |
+
"bevakuf",
|
| 79 |
+
"chinal",
|
| 80 |
+
"gadha",
|
| 81 |
+
"gadhe",
|
| 82 |
+
"gadhi",
|
| 83 |
+
"idiot",
|
| 84 |
+
"jerk",
|
| 85 |
+
"kamina",
|
| 86 |
+
"kamine",
|
| 87 |
+
"kaminey",
|
| 88 |
+
"kamini",
|
| 89 |
+
"kamino",
|
| 90 |
+
"kanjar",
|
| 91 |
+
"kanjari",
|
| 92 |
+
"kanjaron",
|
| 93 |
+
"kutta",
|
| 94 |
+
"kutte",
|
| 95 |
+
"kuttey",
|
| 96 |
+
"kuttay",
|
| 97 |
+
"kutti",
|
| 98 |
+
"kuttia",
|
| 99 |
+
"kuttiya",
|
| 100 |
+
"kutiya",
|
| 101 |
+
"loser",
|
| 102 |
+
"moron",
|
| 103 |
+
"rascal",
|
| 104 |
+
"stupid",
|
| 105 |
+
"suar",
|
| 106 |
+
"suvar",
|
| 107 |
+
"suwar",
|
| 108 |
+
"sooar",
|
| 109 |
+
"tharki",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
HINGLISH_STRONG_INSULT_WORDS = (
|
| 113 |
+
"bhadva",
|
| 114 |
+
"bhadve",
|
| 115 |
+
"bhadwa",
|
| 116 |
+
"bhadwe",
|
| 117 |
+
"bhadwi",
|
| 118 |
+
"harami",
|
| 119 |
+
"haraami",
|
| 120 |
+
"haramkhor",
|
| 121 |
+
"haramzada",
|
| 122 |
+
"haramzade",
|
| 123 |
+
"haramzaadi",
|
| 124 |
+
"haramzadi",
|
| 125 |
+
"najayaz",
|
| 126 |
+
"najayz",
|
| 127 |
+
"randi",
|
| 128 |
+
"randwa",
|
| 129 |
+
"randwe",
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
HINGLISH_IDENTITY_SLUR_WORDS = (
|
| 133 |
+
"chakka",
|
| 134 |
+
"chakke",
|
| 135 |
+
"chhakka",
|
| 136 |
+
"chhakke",
|
| 137 |
+
"hijda",
|
| 138 |
+
"hijde",
|
| 139 |
+
"hijra",
|
| 140 |
+
"hijre",
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
HINGLISH_OBSCENE_WORDS = (
|
| 144 |
+
"asshole",
|
| 145 |
+
"bakchod",
|
| 146 |
+
"bakchodi",
|
| 147 |
+
"bsdk",
|
| 148 |
+
"chodra",
|
| 149 |
+
"chodu",
|
| 150 |
+
"choot",
|
| 151 |
+
"chosi",
|
| 152 |
+
"chot",
|
| 153 |
+
"chuth",
|
| 154 |
+
"chut",
|
| 155 |
+
"chutiya",
|
| 156 |
+
"chutiye",
|
| 157 |
+
"chutiyagiri",
|
| 158 |
+
"chutiyapa",
|
| 159 |
+
"chutiyon",
|
| 160 |
+
"chutia",
|
| 161 |
+
"gaand",
|
| 162 |
+
"gaandu",
|
| 163 |
+
"gand",
|
| 164 |
+
"gandu",
|
| 165 |
+
"lawda",
|
| 166 |
+
"lauda",
|
| 167 |
+
"laude",
|
| 168 |
+
"lavda",
|
| 169 |
+
"loda",
|
| 170 |
+
"lode",
|
| 171 |
+
"lodu",
|
| 172 |
+
"lund",
|
| 173 |
+
"lundi",
|
| 174 |
+
"lundtopi",
|
| 175 |
+
"tatta",
|
| 176 |
+
"tatte",
|
| 177 |
+
"tattey",
|
| 178 |
+
"tatti",
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
HINGLISH_SEVERE_OBSCENE_WORDS = (
|
| 182 |
+
"allahchodi",
|
| 183 |
+
"allachodi",
|
| 184 |
+
"bahanchod",
|
| 185 |
+
"bahenchod",
|
| 186 |
+
"banchod",
|
| 187 |
+
"behenchod",
|
| 188 |
+
"behenchhod",
|
| 189 |
+
"benchod",
|
| 190 |
+
"bhencho",
|
| 191 |
+
"bhenchod",
|
| 192 |
+
"bhenchhod",
|
| 193 |
+
"bhnchod",
|
| 194 |
+
"bhosad",
|
| 195 |
+
"bhosada",
|
| 196 |
+
"bhosadi",
|
| 197 |
+
"bhosadike",
|
| 198 |
+
"bhosadika",
|
| 199 |
+
"bhosadiwala",
|
| 200 |
+
"bhosadiwale",
|
| 201 |
+
"bhosde",
|
| 202 |
+
"bhosday",
|
| 203 |
+
"bhosdi",
|
| 204 |
+
"bhosdika",
|
| 205 |
+
"bhosdiki",
|
| 206 |
+
"bhosdike",
|
| 207 |
+
"bhosdiwala",
|
| 208 |
+
"bhosdiwale",
|
| 209 |
+
"bhosdiwaloon",
|
| 210 |
+
"bhosri",
|
| 211 |
+
"bhosrik",
|
| 212 |
+
"bhosriwala",
|
| 213 |
+
"bhosriwale",
|
| 214 |
+
"bitch",
|
| 215 |
+
"cunt",
|
| 216 |
+
"dickhead",
|
| 217 |
+
"dipshit",
|
| 218 |
+
"fucker",
|
| 219 |
+
"fucking",
|
| 220 |
+
"madarchod",
|
| 221 |
+
"madarchood",
|
| 222 |
+
"madarjaat",
|
| 223 |
+
"madarjat",
|
| 224 |
+
"motherfucker",
|
| 225 |
+
"motherfucking",
|
| 226 |
+
"scumbag",
|
| 227 |
+
"slut",
|
| 228 |
+
"whore",
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
HINDI_LOW_INSULT_WORDS = (
|
| 232 |
+
"अक्लहीन",
|
| 233 |
+
"बकवास",
|
| 234 |
+
"बेकार",
|
| 235 |
+
"जाहिल",
|
| 236 |
+
"घटिया",
|
| 237 |
+
"पागल",
|
| 238 |
+
"नालायक",
|
| 239 |
+
"निकम्मा",
|
| 240 |
+
"निकम्मे",
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
HINDI_MEDIUM_INSULT_WORDS = (
|
| 244 |
+
"बेवकूफ",
|
| 245 |
+
"गधा",
|
| 246 |
+
"गधे",
|
| 247 |
+
"गधी",
|
| 248 |
+
"कमीना",
|
| 249 |
+
"कमीने",
|
| 250 |
+
"कमीनी",
|
| 251 |
+
"कुत्ता",
|
| 252 |
+
"कुत्ते",
|
| 253 |
+
"कुतिया",
|
| 254 |
+
"मूर्ख",
|
| 255 |
+
"गंवार",
|
| 256 |
+
"बदतमीज",
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
HINDI_STRONG_INSULT_WORDS = (
|
| 260 |
+
"हरामी",
|
| 261 |
+
"हरामखोर",
|
| 262 |
+
"हरामज़ादा",
|
| 263 |
+
"हरामजादा",
|
| 264 |
+
"हरामज़ादी",
|
| 265 |
+
"हरामजादी",
|
| 266 |
+
"रंडी",
|
| 267 |
+
"नीच",
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
HINDI_IDENTITY_SLUR_WORDS = (
|
| 271 |
+
"छक्का",
|
| 272 |
+
"छक्के",
|
| 273 |
+
"हिजड़ा",
|
| 274 |
+
"हिजड़े",
|
| 275 |
+
"हिजरा",
|
| 276 |
+
"हिजरे",
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
HINDI_OBSCENE_WORDS = (
|
| 280 |
+
"चूत",
|
| 281 |
+
"चूतिया",
|
| 282 |
+
"चूतिये",
|
| 283 |
+
"चूतियापा",
|
| 284 |
+
"गांड",
|
| 285 |
+
"गाण्ड",
|
| 286 |
+
"गांडू",
|
| 287 |
+
"गाण्डू",
|
| 288 |
+
"लंड",
|
| 289 |
+
"लौड़ा",
|
| 290 |
+
"लौड़े",
|
| 291 |
+
"लवड़ा",
|
| 292 |
+
"टट्टी",
|
| 293 |
+
"टट्टे",
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
HINDI_SEVERE_OBSCENE_WORDS = (
|
| 297 |
+
"बहनचोद",
|
| 298 |
+
"भोसड़ी",
|
| 299 |
+
"भोसडी",
|
| 300 |
+
"भोसड़ीके",
|
| 301 |
+
"भोसडीके",
|
| 302 |
+
"मादरचोद",
|
| 303 |
+
"मदरचोद",
|
| 304 |
+
"मादरजात",
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
ENGLISH_TOKEN_FLOORS = {
|
| 308 |
+
"asshole": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 309 |
+
"bastard": {"toxic": 0.38, "insult": 0.34},
|
| 310 |
+
"bitch": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 311 |
+
"bullshit": {"toxic": 0.44, "obscene": 0.38, "insult": 0.34},
|
| 312 |
+
"crap": {"toxic": 0.34, "insult": 0.31},
|
| 313 |
+
"dickhead": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 314 |
+
"dipshit": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 315 |
+
"dumbass": {"toxic": 0.44, "insult": 0.38},
|
| 316 |
+
"fucker": {"toxic": 0.74, "obscene": 0.68, "insult": 0.60},
|
| 317 |
+
"fucking": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 318 |
+
"idiot": {"toxic": 0.38, "insult": 0.34},
|
| 319 |
+
"jackass": {"toxic": 0.44, "insult": 0.38},
|
| 320 |
+
"jerk": {"toxic": 0.34, "insult": 0.31},
|
| 321 |
+
"loser": {"toxic": 0.38, "insult": 0.34},
|
| 322 |
+
"moron": {"toxic": 0.38, "insult": 0.34},
|
| 323 |
+
"prick": {"toxic": 0.44, "insult": 0.38},
|
| 324 |
+
"retard": {"toxic": 0.44, "insult": 0.38, "identity_hate": 0.34},
|
| 325 |
+
"scumbag": {"toxic": 0.44, "insult": 0.38},
|
| 326 |
+
"shithead": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 327 |
+
"slut": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 328 |
+
"stupid": {"toxic": 0.34, "insult": 0.31},
|
| 329 |
+
"trash": {"toxic": 0.34, "insult": 0.31},
|
| 330 |
+
"whore": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
ENGLISH_PHRASE_FLOORS = {
|
| 334 |
+
"drop dead": {"toxic": 0.44, "threat": 0.38},
|
| 335 |
+
"go kill yourself": {"toxic": 0.56, "threat": 0.52},
|
| 336 |
+
"go to hell": {"toxic": 0.38, "insult": 0.34},
|
| 337 |
+
"i will kill you": {"toxic": 0.56, "threat": 0.52},
|
| 338 |
+
"i'll kill you": {"toxic": 0.56, "threat": 0.52},
|
| 339 |
+
"piece of shit": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 340 |
+
"shut the fuck up": {"toxic": 0.74, "obscene": 0.68, "insult": 0.60},
|
| 341 |
+
"shut up": {"toxic": 0.34, "insult": 0.31},
|
| 342 |
+
"son of a bitch": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _build_token_floors(grouped_words: tuple[tuple[tuple[str, ...], Dict[str, float]], ...]) -> Dict[str, Dict[str, float]]:
|
| 347 |
+
floors: Dict[str, Dict[str, float]] = {}
|
| 348 |
+
for words, labels in grouped_words:
|
| 349 |
+
for word in words:
|
| 350 |
+
floors[word] = dict(labels)
|
| 351 |
+
return floors
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
HINGLISH_TOKEN_FLOORS = _build_token_floors(
|
| 355 |
+
(
|
| 356 |
+
(HINGLISH_LOW_INSULT_WORDS, LOW_INSULT_FLOORS),
|
| 357 |
+
(HINGLISH_MEDIUM_INSULT_WORDS, MEDIUM_INSULT_FLOORS),
|
| 358 |
+
(HINGLISH_STRONG_INSULT_WORDS, STRONG_INSULT_FLOORS),
|
| 359 |
+
(HINGLISH_IDENTITY_SLUR_WORDS, IDENTITY_SLUR_FLOORS),
|
| 360 |
+
(HINGLISH_OBSCENE_WORDS, OBSCENE_FLOORS),
|
| 361 |
+
(HINGLISH_SEVERE_OBSCENE_WORDS, SEVERE_OBSCENE_FLOORS),
|
| 362 |
+
)
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
HINGLISH_PHRASE_FLOORS = {
|
| 366 |
+
"behen chod": {"toxic": 0.74, "obscene": 0.68, "insult": 0.60},
|
| 367 |
+
"bhen chod": {"toxic": 0.74, "obscene": 0.68, "insult": 0.60},
|
| 368 |
+
"gaand mar": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 369 |
+
"jaan se maar": {"toxic": 0.56, "threat": 0.52},
|
| 370 |
+
"kill kar dunga": {"toxic": 0.56, "threat": 0.52},
|
| 371 |
+
"kutte ki aulad": {"toxic": 0.44, "insult": 0.38},
|
| 372 |
+
"maar dunga": {"toxic": 0.52, "threat": 0.48},
|
| 373 |
+
"madar chod": {"toxic": 0.74, "obscene": 0.68, "insult": 0.60},
|
| 374 |
+
"mar dunga": {"toxic": 0.52, "threat": 0.48},
|
| 375 |
+
"mother fucker": {"toxic": 0.74, "obscene": 0.68, "insult": 0.60},
|
| 376 |
+
"piece of shit": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 377 |
+
"son of a bitch": {"toxic": 0.68, "obscene": 0.62, "insult": 0.56},
|
| 378 |
+
"suwar ki aulad": {"toxic": 0.44, "insult": 0.38},
|
| 379 |
+
"teri behen": {"toxic": 0.44, "insult": 0.38},
|
| 380 |
+
"teri ma": {"toxic": 0.44, "insult": 0.38},
|
| 381 |
+
"teri maa": {"toxic": 0.44, "insult": 0.38},
|
| 382 |
+
"tu mar ja": {"toxic": 0.38, "threat": 0.34},
|
| 383 |
+
"ullu ka pattha": {"toxic": 0.38, "insult": 0.34},
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
HINDI_TOKEN_FLOORS = _build_token_floors(
|
| 387 |
+
(
|
| 388 |
+
(HINDI_LOW_INSULT_WORDS, LOW_INSULT_FLOORS),
|
| 389 |
+
(HINDI_MEDIUM_INSULT_WORDS, MEDIUM_INSULT_FLOORS),
|
| 390 |
+
(HINDI_STRONG_INSULT_WORDS, STRONG_INSULT_FLOORS),
|
| 391 |
+
(HINDI_IDENTITY_SLUR_WORDS, IDENTITY_SLUR_FLOORS),
|
| 392 |
+
(HINDI_OBSCENE_WORDS, OBSCENE_FLOORS),
|
| 393 |
+
(HINDI_SEVERE_OBSCENE_WORDS, SEVERE_OBSCENE_FLOORS),
|
| 394 |
+
)
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
HINDI_PHRASE_FLOORS = {
|
| 398 |
+
"जा मर": {"toxic": 0.38, "threat": 0.34},
|
| 399 |
+
"जान से मार दूंगा": {"toxic": 0.56, "threat": 0.52},
|
| 400 |
+
"जान से मार दूँगा": {"toxic": 0.56, "threat": 0.52},
|
| 401 |
+
"तेरी बहन": {"toxic": 0.44, "insult": 0.38},
|
| 402 |
+
"तेरी मां": {"toxic": 0.44, "insult": 0.38},
|
| 403 |
+
"तेरी माँ": {"toxic": 0.44, "insult": 0.38},
|
| 404 |
+
"मार दूंगा": {"toxic": 0.52, "threat": 0.48},
|
| 405 |
+
"मार दूँगा": {"toxic": 0.52, "threat": 0.48},
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
class ToxicityClassifier:
|
| 409 |
+
"""
|
| 410 |
+
Toxicity classifier powered by fine-tuned MuRIL base.
|
| 411 |
+
"""
|
| 412 |
+
|
| 413 |
+
def __init__(self, model_name: str = settings.CLASSIFIER_MODEL, device: str = settings.DEVICE):
|
| 414 |
+
self.device = device
|
| 415 |
+
self._loaded = False
|
| 416 |
+
self._model_name = model_name
|
| 417 |
+
|
| 418 |
+
self.tokenizer = None
|
| 419 |
+
self.model = None
|
| 420 |
+
self.model_version = settings.APP_VERSION
|
| 421 |
+
|
| 422 |
+
def load(self) -> None:
|
| 423 |
+
"""Load MuRIL model into memory."""
|
| 424 |
+
logger.info(f"Loading tokenizer and model ({self._model_name}) on {self.device}...")
|
| 425 |
+
try:
|
| 426 |
+
# MuRIL can require protobuf-backed slow tokenizer loading in lean container environments.
|
| 427 |
+
self.tokenizer = AutoTokenizer.from_pretrained(self._model_name, use_fast=False)
|
| 428 |
+
self.model = AutoModelForSequenceClassification.from_pretrained(
|
| 429 |
+
self._model_name,
|
| 430 |
+
num_labels=len(LABELS),
|
| 431 |
+
problem_type="multi_label_classification"
|
| 432 |
+
)
|
| 433 |
+
# If the model hasn't been fine-tuned yet, it will throw a warning about randomly initialized weights.
|
| 434 |
+
# We explicitly ignore here for first-time startup.
|
| 435 |
+
self.model.to(self.device)
|
| 436 |
+
self.model.eval()
|
| 437 |
+
|
| 438 |
+
self._loaded = True
|
| 439 |
+
logger.success("MuRIL toxicity model loaded successfully.")
|
| 440 |
+
except Exception as e:
|
| 441 |
+
logger.error(f"Failed to load MuRIL model: {e}")
|
| 442 |
+
raise e
|
| 443 |
+
|
| 444 |
+
@property
|
| 445 |
+
def is_loaded(self) -> bool:
|
| 446 |
+
return self._loaded
|
| 447 |
+
|
| 448 |
+
def predict(self, text: str, context: Optional[list[str]] = None) -> Dict:
|
| 449 |
+
"""
|
| 450 |
+
Classify text for toxicity using MuRIL.
|
| 451 |
+
"""
|
| 452 |
+
if not self._loaded:
|
| 453 |
+
raise RuntimeError("Model not loaded. Call classifier.load() first.")
|
| 454 |
+
|
| 455 |
+
start_time = time.perf_counter()
|
| 456 |
+
|
| 457 |
+
# Step 1: Preprocess
|
| 458 |
+
normalized = normalize_for_toxicity(text)
|
| 459 |
+
lang = detect_language(text) # "en", "hi", "hi-en"
|
| 460 |
+
model_input = normalized
|
| 461 |
+
|
| 462 |
+
# Incorporate up to 4 past messages for context if provided
|
| 463 |
+
if context and len(context) > 0:
|
| 464 |
+
context_str = " [SEP] ".join(context[-4:])
|
| 465 |
+
model_input = f"{context_str} [SEP] {normalized}"
|
| 466 |
+
|
| 467 |
+
# Step 2: Tokenize
|
| 468 |
+
inputs = self.tokenizer(
|
| 469 |
+
model_input,
|
| 470 |
+
return_tensors="pt",
|
| 471 |
+
max_length=settings.MAX_SEQ_LENGTH,
|
| 472 |
+
truncation=True,
|
| 473 |
+
padding=True,
|
| 474 |
+
)
|
| 475 |
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 476 |
+
|
| 477 |
+
# Step 3: Inference
|
| 478 |
+
with torch.no_grad():
|
| 479 |
+
outputs = self.model(**inputs)
|
| 480 |
+
logits = outputs.logits
|
| 481 |
+
# Sigmoid is used for multi-label classification to get probabilities 0-1
|
| 482 |
+
probs = torch.sigmoid(logits)[0].cpu().numpy().tolist()
|
| 483 |
+
|
| 484 |
+
# Step 4: Map back to Categories
|
| 485 |
+
categories = {LABELS[i]: float(probs[i]) for i in range(len(LABELS))}
|
| 486 |
+
categories = self._apply_hindi_lexicon_boost(normalized, categories, lang)
|
| 487 |
+
categories = self._apply_hinglish_lexicon_boost(normalized, categories, lang)
|
| 488 |
+
categories = self._apply_english_lexicon_boost(normalized, categories, lang)
|
| 489 |
+
|
| 490 |
+
# Step 5: Overall score + severity
|
| 491 |
+
overall_score = round(max(categories.values()), 4)
|
| 492 |
+
severity = self._score_to_severity(overall_score, lang)
|
| 493 |
+
categories = {label: round(score, 4) for label, score in categories.items()}
|
| 494 |
+
|
| 495 |
+
inference_time_ms = int((time.perf_counter() - start_time) * 1000)
|
| 496 |
+
|
| 497 |
+
return {
|
| 498 |
+
"is_toxic": overall_score >= self._safe_threshold_for_language(lang),
|
| 499 |
+
"overall_score": overall_score,
|
| 500 |
+
"severity": severity,
|
| 501 |
+
"categories": categories,
|
| 502 |
+
"detected_language": lang,
|
| 503 |
+
"ensemble_weights": {"muril": 1.0}, # Single model now
|
| 504 |
+
"model_version": self.model_version,
|
| 505 |
+
"inference_time_ms": inference_time_ms,
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
def predict_batch(self, texts: list[str]) -> list[Dict]:
|
| 509 |
+
"""Classify a batch of texts."""
|
| 510 |
+
return [self.predict(text) for text in texts]
|
| 511 |
+
|
| 512 |
+
@staticmethod
|
| 513 |
+
def _safe_threshold_for_language(lang: str) -> float:
|
| 514 |
+
if lang == "hi-en":
|
| 515 |
+
return settings.THRESHOLD_SAFE_HINGLISH
|
| 516 |
+
return settings.THRESHOLD_SAFE
|
| 517 |
+
|
| 518 |
+
@classmethod
|
| 519 |
+
def _score_to_severity(cls, score: float, lang: str) -> str:
|
| 520 |
+
"""Map a toxicity score to a severity level."""
|
| 521 |
+
safe_threshold = cls._safe_threshold_for_language(lang)
|
| 522 |
+
if score < safe_threshold:
|
| 523 |
+
return "SAFE"
|
| 524 |
+
elif score < settings.THRESHOLD_LOW:
|
| 525 |
+
return "LOW"
|
| 526 |
+
elif score < settings.THRESHOLD_MEDIUM:
|
| 527 |
+
return "MEDIUM"
|
| 528 |
+
else:
|
| 529 |
+
return "HIGH"
|
| 530 |
+
|
| 531 |
+
@staticmethod
|
| 532 |
+
def _apply_hinglish_lexicon_boost(text: str, categories: Dict[str, float], lang: str) -> Dict[str, float]:
|
| 533 |
+
"""Boost missed Hinglish toxicity for common romanized abuse terms."""
|
| 534 |
+
if lang != "hi-en":
|
| 535 |
+
return categories
|
| 536 |
+
|
| 537 |
+
return ToxicityClassifier._apply_lexicon_floors(
|
| 538 |
+
text=text,
|
| 539 |
+
categories=categories,
|
| 540 |
+
token_floors=HINGLISH_TOKEN_FLOORS,
|
| 541 |
+
phrase_floors=HINGLISH_PHRASE_FLOORS,
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
@staticmethod
|
| 545 |
+
def _apply_hindi_lexicon_boost(text: str, categories: Dict[str, float], lang: str) -> Dict[str, float]:
|
| 546 |
+
"""Boost missed Hindi-script toxicity for common slurs, obscenity, and threat phrases."""
|
| 547 |
+
if lang != "hi":
|
| 548 |
+
return categories
|
| 549 |
+
|
| 550 |
+
return ToxicityClassifier._apply_lexicon_floors(
|
| 551 |
+
text=text,
|
| 552 |
+
categories=categories,
|
| 553 |
+
token_floors=HINDI_TOKEN_FLOORS,
|
| 554 |
+
phrase_floors=HINDI_PHRASE_FLOORS,
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
@staticmethod
|
| 558 |
+
def _apply_english_lexicon_boost(text: str, categories: Dict[str, float], lang: str) -> Dict[str, float]:
|
| 559 |
+
"""Boost obvious English profanity and insults the base model may under-score."""
|
| 560 |
+
if lang != "en":
|
| 561 |
+
return categories
|
| 562 |
+
|
| 563 |
+
return ToxicityClassifier._apply_lexicon_floors(
|
| 564 |
+
text=text,
|
| 565 |
+
categories=categories,
|
| 566 |
+
token_floors=ENGLISH_TOKEN_FLOORS,
|
| 567 |
+
phrase_floors=ENGLISH_PHRASE_FLOORS,
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
@staticmethod
|
| 571 |
+
def _apply_lexicon_floors(
|
| 572 |
+
text: str,
|
| 573 |
+
categories: Dict[str, float],
|
| 574 |
+
token_floors: Dict[str, Dict[str, float]],
|
| 575 |
+
phrase_floors: Dict[str, Dict[str, float]],
|
| 576 |
+
) -> Dict[str, float]:
|
| 577 |
+
"""Apply token and phrase-based minimum scores for language-specific abuse lexicons."""
|
| 578 |
+
boosted = dict(categories)
|
| 579 |
+
lowered = text.lower()
|
| 580 |
+
tokens = set(re.findall(r"[a-z\u0900-\u097f]+", lowered))
|
| 581 |
+
|
| 582 |
+
for token, floors in token_floors.items():
|
| 583 |
+
if token in tokens:
|
| 584 |
+
for label, floor in floors.items():
|
| 585 |
+
boosted[label] = max(boosted[label], floor)
|
| 586 |
+
|
| 587 |
+
for phrase, floors in phrase_floors.items():
|
| 588 |
+
if phrase in lowered:
|
| 589 |
+
for label, floor in floors.items():
|
| 590 |
+
boosted[label] = max(boosted[label], floor)
|
| 591 |
+
|
| 592 |
+
return boosted
|
| 593 |
+
|
| 594 |
+
def get_info(self) -> Dict:
|
| 595 |
+
"""Return model metadata for health checks."""
|
| 596 |
+
return {
|
| 597 |
+
"muril_model": {
|
| 598 |
+
"name": self._model_name,
|
| 599 |
+
"loaded": self._loaded,
|
| 600 |
+
},
|
| 601 |
+
"device": self.device,
|
| 602 |
+
"version": self.model_version,
|
| 603 |
+
}
|
app/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Schemas package
|
app/schemas/feedback.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Feedback API Schemas (Pydantic v2)
|
| 3 |
+
|
| 4 |
+
Used by the moderator dashboard to submit corrections,
|
| 5 |
+
which feed into the continuous learning pipeline.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class FeedbackRequest(BaseModel):
|
| 14 |
+
"""Request body for POST /api/v1/feedback"""
|
| 15 |
+
message_id: str = Field(..., description="MongoDB ObjectId of the moderated message")
|
| 16 |
+
moderator_id: str = Field(..., description="UUID of the moderator")
|
| 17 |
+
model_prediction_was_correct: bool = Field(
|
| 18 |
+
..., description="Did the model classify correctly?"
|
| 19 |
+
)
|
| 20 |
+
correct_label: Optional[str] = Field(
|
| 21 |
+
None,
|
| 22 |
+
description="Correct toxicity label if model was wrong (e.g., 'not_toxic', 'toxic', 'insult')",
|
| 23 |
+
)
|
| 24 |
+
correct_severity: Optional[str] = Field(
|
| 25 |
+
None, description="Correct severity if model was wrong (SAFE/LOW/MEDIUM/HIGH)"
|
| 26 |
+
)
|
| 27 |
+
notes: Optional[str] = Field(
|
| 28 |
+
None, max_length=1000, description="Moderator notes explaining the correction"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class FeedbackResponse(BaseModel):
|
| 33 |
+
"""Response body for POST /api/v1/feedback"""
|
| 34 |
+
feedback_id: str
|
| 35 |
+
message: str
|
| 36 |
+
total_feedback_count: int
|
| 37 |
+
retrain_threshold: int
|
| 38 |
+
retrain_triggered: bool
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class FeedbackStats(BaseModel):
|
| 42 |
+
"""Response body for GET /api/v1/feedback/stats"""
|
| 43 |
+
total_feedback: int
|
| 44 |
+
correct_predictions: int
|
| 45 |
+
incorrect_predictions: int
|
| 46 |
+
accuracy: float = Field(ge=0, le=1)
|
| 47 |
+
feedback_since_last_retrain: int
|
| 48 |
+
retrain_threshold: int
|
| 49 |
+
next_retrain_at: int # Feedback count needed to trigger retrain
|
| 50 |
+
last_retrain_at: Optional[datetime] = None
|
app/schemas/moderation.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Moderation API Schemas (Pydantic v2)
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Dict, Optional
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ModerationRequest(BaseModel):
|
| 10 |
+
"""Request body for POST /api/v1/moderate"""
|
| 11 |
+
text: str = Field(..., min_length=1, max_length=5000, description="Text to moderate")
|
| 12 |
+
channel_id: Optional[str] = Field(None, description="Channel ID for policy lookup")
|
| 13 |
+
user_id: Optional[str] = Field(None, description="User ID for tracking")
|
| 14 |
+
context: Optional[list[str]] = Field(default_factory=list, description="List of previous messages for conversation context")
|
| 15 |
+
|
| 16 |
+
model_config = {"json_schema_extra": {
|
| 17 |
+
"examples": [
|
| 18 |
+
{
|
| 19 |
+
"text": "tu bahut bada bewakoof hai bro",
|
| 20 |
+
"channel_id": "general",
|
| 21 |
+
"user_id": "user-123",
|
| 22 |
+
"context": ["Hi, how are you?", "I am fine, but you are annoying"]
|
| 23 |
+
}
|
| 24 |
+
]
|
| 25 |
+
}}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ToxicityCategories(BaseModel):
|
| 29 |
+
"""Breakdown of toxicity scores by category."""
|
| 30 |
+
toxic: float = Field(ge=0, le=1)
|
| 31 |
+
severe_toxic: float = Field(ge=0, le=1)
|
| 32 |
+
obscene: float = Field(ge=0, le=1)
|
| 33 |
+
identity_hate: float = Field(ge=0, le=1)
|
| 34 |
+
insult: float = Field(ge=0, le=1)
|
| 35 |
+
threat: float = Field(ge=0, le=1)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class EnsembleWeights(BaseModel):
|
| 39 |
+
"""Weights used for the ensemble prediction."""
|
| 40 |
+
en_model: float
|
| 41 |
+
multi_model: float
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ModerationResponse(BaseModel):
|
| 45 |
+
"""Response body for POST /api/v1/moderate"""
|
| 46 |
+
is_toxic: bool
|
| 47 |
+
overall_score: float = Field(ge=0, le=1)
|
| 48 |
+
severity: str = Field(description="SAFE | LOW | MEDIUM | HIGH")
|
| 49 |
+
categories: Dict[str, float]
|
| 50 |
+
detected_language: str
|
| 51 |
+
ensemble_weights: Dict[str, float]
|
| 52 |
+
suggestion: Optional[str] = Field(None, description="Polite alternative (if toxic)")
|
| 53 |
+
suggestions: Optional[list[str]] = Field(None, description="Alternative polite rewrites (if toxic)")
|
| 54 |
+
model_version: str
|
| 55 |
+
inference_time_ms: int
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class DetoxifyRequest(BaseModel):
|
| 59 |
+
"""Request body for POST /api/v1/detoxify"""
|
| 60 |
+
text: str = Field(..., min_length=1, max_length=5000)
|
| 61 |
+
target_language: Optional[str] = Field(None, description="Override language detection")
|
| 62 |
+
preserve_intent: bool = Field(True, description="Try to preserve the original meaning")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class DetoxifyResponse(BaseModel):
|
| 66 |
+
"""Response body for POST /api/v1/detoxify"""
|
| 67 |
+
original: str
|
| 68 |
+
detoxified: str
|
| 69 |
+
suggestions: list[str] = Field(default_factory=list, description="Alternative polite rewrites")
|
| 70 |
+
method: str = Field(description="'passthrough' | 'template' | 'indic_bart'")
|
| 71 |
+
language: str
|
| 72 |
+
confidence: float = Field(ge=0, le=1)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class BatchModerationRequest(BaseModel):
|
| 76 |
+
"""Request body for POST /api/v1/moderate/batch"""
|
| 77 |
+
texts: list[str] = Field(..., min_length=1, max_length=50)
|
| 78 |
+
channel_id: Optional[str] = None
|
| 79 |
+
user_id: Optional[str] = None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class BatchModerationResponse(BaseModel):
|
| 83 |
+
"""Response body for POST /api/v1/moderate/batch"""
|
| 84 |
+
results: list[ModerationResponse]
|
| 85 |
+
total_inference_time_ms: int
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services package
|
app/services/feedback_service.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Feedback Service
|
| 3 |
+
|
| 4 |
+
Handles moderator feedback storage and continuous learning triggers.
|
| 5 |
+
Feedback is stored in MongoDB and used to retrain models when
|
| 6 |
+
the threshold is reached.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Dict, Optional
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
from loguru import logger
|
| 13 |
+
|
| 14 |
+
from app.config import settings
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FeedbackService:
|
| 18 |
+
"""
|
| 19 |
+
Manages the moderator feedback loop.
|
| 20 |
+
|
| 21 |
+
Flow:
|
| 22 |
+
1. Moderator reviews a flagged message
|
| 23 |
+
2. Submits correction via POST /api/v1/feedback
|
| 24 |
+
3. Feedback stored in MongoDB
|
| 25 |
+
4. When feedback count reaches threshold → trigger retraining
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self):
|
| 29 |
+
self._feedback_count = 0
|
| 30 |
+
self._correct_count = 0
|
| 31 |
+
self._incorrect_count = 0
|
| 32 |
+
self._last_retrain_at: Optional[datetime] = None
|
| 33 |
+
self._feedback_since_retrain = 0
|
| 34 |
+
|
| 35 |
+
# In-memory store (replaced by MongoDB in production)
|
| 36 |
+
self._feedback_store: list[Dict] = []
|
| 37 |
+
|
| 38 |
+
async def submit_feedback(self, feedback: Dict) -> Dict:
|
| 39 |
+
"""
|
| 40 |
+
Store moderator feedback and check if retraining should be triggered.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
feedback: Dict with message_id, moderator_id, correct_label, etc.
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Dict with feedback_id, counts, and whether retrain was triggered
|
| 47 |
+
"""
|
| 48 |
+
# Store feedback
|
| 49 |
+
feedback_entry = {
|
| 50 |
+
**feedback,
|
| 51 |
+
"feedback_id": f"fb-{self._feedback_count + 1}",
|
| 52 |
+
"submitted_at": datetime.utcnow().isoformat(),
|
| 53 |
+
}
|
| 54 |
+
self._feedback_store.append(feedback_entry)
|
| 55 |
+
|
| 56 |
+
# Update counters
|
| 57 |
+
self._feedback_count += 1
|
| 58 |
+
self._feedback_since_retrain += 1
|
| 59 |
+
|
| 60 |
+
if feedback.get("model_prediction_was_correct", True):
|
| 61 |
+
self._correct_count += 1
|
| 62 |
+
else:
|
| 63 |
+
self._incorrect_count += 1
|
| 64 |
+
|
| 65 |
+
# Check if retraining should be triggered
|
| 66 |
+
retrain_triggered = False
|
| 67 |
+
if self._feedback_since_retrain >= settings.FEEDBACK_THRESHOLD_FOR_RETRAIN:
|
| 68 |
+
retrain_triggered = await self._trigger_retraining()
|
| 69 |
+
|
| 70 |
+
logger.info(
|
| 71 |
+
f"Feedback #{self._feedback_count} received. "
|
| 72 |
+
f"Correct: {self._correct_count}, Incorrect: {self._incorrect_count}. "
|
| 73 |
+
f"Retrain triggered: {retrain_triggered}"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"feedback_id": feedback_entry["feedback_id"],
|
| 78 |
+
"message": "Feedback recorded successfully",
|
| 79 |
+
"total_feedback_count": self._feedback_count,
|
| 80 |
+
"retrain_threshold": settings.FEEDBACK_THRESHOLD_FOR_RETRAIN,
|
| 81 |
+
"retrain_triggered": retrain_triggered,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
async def get_stats(self) -> Dict:
|
| 85 |
+
"""Return feedback statistics."""
|
| 86 |
+
accuracy = (
|
| 87 |
+
self._correct_count / self._feedback_count
|
| 88 |
+
if self._feedback_count > 0
|
| 89 |
+
else 0.0
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
"total_feedback": self._feedback_count,
|
| 94 |
+
"correct_predictions": self._correct_count,
|
| 95 |
+
"incorrect_predictions": self._incorrect_count,
|
| 96 |
+
"accuracy": round(accuracy, 4),
|
| 97 |
+
"feedback_since_last_retrain": self._feedback_since_retrain,
|
| 98 |
+
"retrain_threshold": settings.FEEDBACK_THRESHOLD_FOR_RETRAIN,
|
| 99 |
+
"next_retrain_at": max(
|
| 100 |
+
0,
|
| 101 |
+
settings.FEEDBACK_THRESHOLD_FOR_RETRAIN - self._feedback_since_retrain,
|
| 102 |
+
),
|
| 103 |
+
"last_retrain_at": self._last_retrain_at,
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
async def _trigger_retraining(self) -> bool:
|
| 107 |
+
"""
|
| 108 |
+
Trigger model retraining.
|
| 109 |
+
|
| 110 |
+
In production, this would:
|
| 111 |
+
1. Export feedback data from MongoDB
|
| 112 |
+
2. Combine with original training data
|
| 113 |
+
3. Fine-tune models (background job)
|
| 114 |
+
4. Evaluate new model on holdout set
|
| 115 |
+
5. Hot-swap if improved
|
| 116 |
+
"""
|
| 117 |
+
logger.warning(
|
| 118 |
+
f"Retraining threshold reached ({self._feedback_since_retrain} feedback samples). "
|
| 119 |
+
f"Triggering retraining pipeline..."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# TODO: Implement actual retraining pipeline
|
| 123 |
+
# For now, just reset the counter and log
|
| 124 |
+
self._feedback_since_retrain = 0
|
| 125 |
+
self._last_retrain_at = datetime.utcnow()
|
| 126 |
+
|
| 127 |
+
logger.info("Retraining pipeline placeholder executed. Reset feedback counter.")
|
| 128 |
+
return True
|
| 129 |
+
|
| 130 |
+
def get_training_data(self) -> list[Dict]:
|
| 131 |
+
"""Export feedback data for retraining."""
|
| 132 |
+
return [
|
| 133 |
+
fb for fb in self._feedback_store
|
| 134 |
+
if not fb.get("model_prediction_was_correct", True)
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# Singleton instance
|
| 139 |
+
feedback_service = FeedbackService()
|
app/services/moderation_service.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SafeChat — Moderation Service
|
| 3 |
+
|
| 4 |
+
Orchestrates the full moderation pipeline:
|
| 5 |
+
1. Classify text for toxicity (ensemble)
|
| 6 |
+
2. Generate polite alternative (if toxic)
|
| 7 |
+
3. Return combined result
|
| 8 |
+
|
| 9 |
+
This is the main entry point called by the API routes.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import time
|
| 13 |
+
from typing import Dict, Optional
|
| 14 |
+
|
| 15 |
+
from loguru import logger
|
| 16 |
+
|
| 17 |
+
from app.models.model_manager import model_manager
|
| 18 |
+
from app.schemas.moderation import ModerationResponse
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ModerationService:
|
| 22 |
+
"""
|
| 23 |
+
Orchestrates toxicity classification + detoxification.
|
| 24 |
+
|
| 25 |
+
Stateless service — all state lives in ModelManager.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
@staticmethod
|
| 29 |
+
async def moderate(
|
| 30 |
+
text: str,
|
| 31 |
+
context: Optional[list[str]] = None,
|
| 32 |
+
channel_id: Optional[str] = None,
|
| 33 |
+
user_id: Optional[str] = None,
|
| 34 |
+
) -> ModerationResponse:
|
| 35 |
+
"""
|
| 36 |
+
Full moderation pipeline for a single message.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
text: Raw message text
|
| 40 |
+
channel_id: Channel for policy lookup (future use)
|
| 41 |
+
user_id: Sender ID for tracking (future use)
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
ModerationResponse with toxicity scores and suggestion
|
| 45 |
+
"""
|
| 46 |
+
start_time = time.perf_counter()
|
| 47 |
+
|
| 48 |
+
# Step 1: Classify toxicity
|
| 49 |
+
classifier = model_manager.classifier
|
| 50 |
+
if not classifier or not classifier.is_loaded:
|
| 51 |
+
raise RuntimeError("Toxicity classifier not available")
|
| 52 |
+
|
| 53 |
+
classification = classifier.predict(text, context=context)
|
| 54 |
+
|
| 55 |
+
# Step 2: Generate suggestion if toxic
|
| 56 |
+
suggestion = None
|
| 57 |
+
suggestions = None
|
| 58 |
+
if classification["is_toxic"]:
|
| 59 |
+
detoxifier = model_manager.detoxifier
|
| 60 |
+
if detoxifier:
|
| 61 |
+
detox_result = detoxifier.detoxify(
|
| 62 |
+
text=text,
|
| 63 |
+
toxicity_categories=classification["categories"],
|
| 64 |
+
target_language=classification["detected_language"],
|
| 65 |
+
preserve_intent=True,
|
| 66 |
+
)
|
| 67 |
+
suggestion = detox_result["detoxified"]
|
| 68 |
+
suggestions = detox_result.get("suggestions")
|
| 69 |
+
|
| 70 |
+
total_time_ms = int((time.perf_counter() - start_time) * 1000)
|
| 71 |
+
|
| 72 |
+
return ModerationResponse(
|
| 73 |
+
is_toxic=classification["is_toxic"],
|
| 74 |
+
overall_score=classification["overall_score"],
|
| 75 |
+
severity=classification["severity"],
|
| 76 |
+
categories=classification["categories"],
|
| 77 |
+
detected_language=classification["detected_language"],
|
| 78 |
+
ensemble_weights=classification["ensemble_weights"],
|
| 79 |
+
suggestion=suggestion,
|
| 80 |
+
suggestions=suggestions,
|
| 81 |
+
model_version=classification["model_version"],
|
| 82 |
+
inference_time_ms=total_time_ms,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
async def moderate_batch(
|
| 87 |
+
texts: list[str],
|
| 88 |
+
channel_id: Optional[str] = None,
|
| 89 |
+
user_id: Optional[str] = None,
|
| 90 |
+
) -> list[ModerationResponse]:
|
| 91 |
+
"""Moderate multiple messages. Simple sequential for now."""
|
| 92 |
+
results = []
|
| 93 |
+
for text in texts:
|
| 94 |
+
result = await ModerationService.moderate(
|
| 95 |
+
text=text,
|
| 96 |
+
channel_id=channel_id,
|
| 97 |
+
user_id=user_id,
|
| 98 |
+
)
|
| 99 |
+
results.append(result)
|
| 100 |
+
return results
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# Singleton service instance
|
| 104 |
+
moderation_service = ModerationService()
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utils package
|
app/utils/preprocessing.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Text Preprocessing for SafeChat
|
| 3 |
+
|
| 4 |
+
Handles text normalization, language detection (with Hinglish/code-mixing support),
|
| 5 |
+
and cleaning for optimal model input.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
import unicodedata
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from loguru import logger
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ── Script Detection (for code-mixed language identification) ───────────
|
| 16 |
+
|
| 17 |
+
# Unicode ranges for Indian scripts
|
| 18 |
+
DEVANAGARI_RANGE = re.compile(r"[\u0900-\u097F]") # Hindi, Sanskrit, Marathi
|
| 19 |
+
BENGALI_RANGE = re.compile(r"[\u0980-\u09FF]") # Bengali, Assamese
|
| 20 |
+
TAMIL_RANGE = re.compile(r"[\u0B80-\u0BFF]")
|
| 21 |
+
TELUGU_RANGE = re.compile(r"[\u0C00-\u0C7F]")
|
| 22 |
+
KANNADA_RANGE = re.compile(r"[\u0C80-\u0CFF]")
|
| 23 |
+
MALAYALAM_RANGE = re.compile(r"[\u0D00-\u0D7F]")
|
| 24 |
+
GUJARATI_RANGE = re.compile(r"[\u0A80-\u0AFF]")
|
| 25 |
+
GURMUKHI_RANGE = re.compile(r"[\u0A00-\u0A7F]") # Punjabi
|
| 26 |
+
ODIA_RANGE = re.compile(r"[\u0B00-\u0B7F]")
|
| 27 |
+
LATIN_RANGE = re.compile(r"[a-zA-Z]")
|
| 28 |
+
|
| 29 |
+
INDIAN_SCRIPT_MAP = {
|
| 30 |
+
"devanagari": DEVANAGARI_RANGE,
|
| 31 |
+
"bengali": BENGALI_RANGE,
|
| 32 |
+
"tamil": TAMIL_RANGE,
|
| 33 |
+
"telugu": TELUGU_RANGE,
|
| 34 |
+
"kannada": KANNADA_RANGE,
|
| 35 |
+
"malayalam": MALAYALAM_RANGE,
|
| 36 |
+
"gujarati": GUJARATI_RANGE,
|
| 37 |
+
"gurmukhi": GURMUKHI_RANGE,
|
| 38 |
+
"odia": ODIA_RANGE,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def detect_language(text: str) -> str:
|
| 43 |
+
"""
|
| 44 |
+
Detect language with special handling for Indian languages and code-mixing.
|
| 45 |
+
|
| 46 |
+
Returns standardized language codes:
|
| 47 |
+
- 'en' : English
|
| 48 |
+
- 'hi' : Hindi (Devanagari script)
|
| 49 |
+
- 'hi-en' : Hinglish (code-mixed Hindi + English)
|
| 50 |
+
- 'bn' : Bengali
|
| 51 |
+
- 'ta' : Tamil
|
| 52 |
+
- 'te' : Telugu
|
| 53 |
+
- 'kn' : Kannada
|
| 54 |
+
- 'ml' : Malayalam
|
| 55 |
+
- 'gu' : Gujarati
|
| 56 |
+
- 'pa' : Punjabi
|
| 57 |
+
- 'or' : Odia
|
| 58 |
+
- 'indic-en' : Any Indian language mixed with English
|
| 59 |
+
- 'other' : Fallback
|
| 60 |
+
|
| 61 |
+
NOTE: This script-based detection is MORE RELIABLE for code-mixed text
|
| 62 |
+
than library-based detectors (langdetect/fasttext) which assume monolingual input.
|
| 63 |
+
"""
|
| 64 |
+
if not text or not text.strip():
|
| 65 |
+
return "en"
|
| 66 |
+
|
| 67 |
+
has_latin = bool(LATIN_RANGE.search(text))
|
| 68 |
+
|
| 69 |
+
# Check each Indian script
|
| 70 |
+
detected_scripts = {}
|
| 71 |
+
for script_name, pattern in INDIAN_SCRIPT_MAP.items():
|
| 72 |
+
matches = pattern.findall(text)
|
| 73 |
+
if matches:
|
| 74 |
+
detected_scripts[script_name] = len(matches)
|
| 75 |
+
|
| 76 |
+
# No Indian script detected
|
| 77 |
+
if not detected_scripts:
|
| 78 |
+
if has_latin:
|
| 79 |
+
# Could be transliterated Hindi (romanized) — check with langdetect
|
| 80 |
+
return _detect_romanized_indian(text)
|
| 81 |
+
return "en"
|
| 82 |
+
|
| 83 |
+
# Find dominant Indian script
|
| 84 |
+
dominant_script = max(detected_scripts, key=detected_scripts.get)
|
| 85 |
+
|
| 86 |
+
# Map script to language code
|
| 87 |
+
script_to_lang = {
|
| 88 |
+
"devanagari": "hi",
|
| 89 |
+
"bengali": "bn",
|
| 90 |
+
"tamil": "ta",
|
| 91 |
+
"telugu": "te",
|
| 92 |
+
"kannada": "kn",
|
| 93 |
+
"malayalam": "ml",
|
| 94 |
+
"gujarati": "gu",
|
| 95 |
+
"gurmukhi": "pa",
|
| 96 |
+
"odia": "or",
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
lang = script_to_lang.get(dominant_script, "other")
|
| 100 |
+
|
| 101 |
+
# Check for code-mixing (Indian script + significant Latin text)
|
| 102 |
+
if has_latin and detected_scripts:
|
| 103 |
+
latin_chars = len(LATIN_RANGE.findall(text))
|
| 104 |
+
indian_chars = sum(detected_scripts.values())
|
| 105 |
+
total = latin_chars + indian_chars
|
| 106 |
+
|
| 107 |
+
# If more than 20% of script chars are Latin, it's code-mixed
|
| 108 |
+
if total > 0 and latin_chars / total > 0.2:
|
| 109 |
+
if lang == "hi":
|
| 110 |
+
return "hi-en" # Hinglish
|
| 111 |
+
return "indic-en" # Other Indian + English mix
|
| 112 |
+
|
| 113 |
+
return lang
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _detect_romanized_indian(text: str) -> str:
|
| 117 |
+
"""
|
| 118 |
+
Detect if Latin-script text is actually romanized Hindi/Hinglish.
|
| 119 |
+
|
| 120 |
+
Uses common Hindi words written in Latin script as indicators.
|
| 121 |
+
"""
|
| 122 |
+
# Common romanized Hindi words (colloquial + formal)
|
| 123 |
+
hindi_indicators = {
|
| 124 |
+
# Pronouns and common words
|
| 125 |
+
"kya", "hai", "hain", "nahi", "nhi", "mat", "aur", "bhi", "toh",
|
| 126 |
+
"mein", "main", "tera", "mera", "tumhara", "hamara", "apna",
|
| 127 |
+
"yeh", "woh", "koi", "kuch", "sab", "bahut", "bohot",
|
| 128 |
+
# Verbs
|
| 129 |
+
"karo", "karna", "bolo", "bolna", "jao", "jana", "aao", "aana",
|
| 130 |
+
"dekho", "dekhna", "suno", "sunna", "chalo", "ruk", "ruko",
|
| 131 |
+
# Slang / colloquial
|
| 132 |
+
"yaar", "bhai", "dude", "arre", "abey", "oye", "chal",
|
| 133 |
+
"accha", "theek", "sahi", "galat", "bakwas", "pagal",
|
| 134 |
+
# Toxicity indicators (important for our use case)
|
| 135 |
+
"bewakoof", "gadha", "ullu", "kamina", "kamini", "harami",
|
| 136 |
+
"chutiya", "madarchod", "behenchod", "bhosdike", "gaandu",
|
| 137 |
+
"saala", "saali", "kutte", "kuttia", "haramkhor",
|
| 138 |
+
# Expanded romanized abuse cues so short profanity-heavy chats are
|
| 139 |
+
# still routed through the Hinglish path.
|
| 140 |
+
"badir", "badirchand", "bakchod", "bakchodi", "bakland",
|
| 141 |
+
"baklol", "baklund", "bakwaas", "bhenchod", "bhosdi",
|
| 142 |
+
"bhosdika", "bhosdiki", "bhosde", "bhadwa", "bhadwe",
|
| 143 |
+
"bsdk", "chakka", "chhakka", "chinal", "chodu", "chut",
|
| 144 |
+
"chutiye", "chutiyapa", "gaand", "gandu", "ghatiya", "gawar",
|
| 145 |
+
"haramzada", "haramzade", "hijda", "hijde", "hijra", "jahil",
|
| 146 |
+
"jhantu", "jhandu", "kanjar", "kanjari", "kaminey", "kutta",
|
| 147 |
+
"kuttey", "kuttay",
|
| 148 |
+
"lauda", "lavda", "loda", "lodu", "lund", "lundtopi",
|
| 149 |
+
"madarchod", "madarchood", "najayaz",
|
| 150 |
+
"nalayak", "nikamma", "paagal", "randi", "randwa", "randwe",
|
| 151 |
+
"sala", "saale", "sali", "stupid", "suar", "suwar",
|
| 152 |
+
"tharki", "tatti", "tatte",
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
# Tokenize defensively so "bhosdike," still counts as a Hinglish cue.
|
| 156 |
+
words = set(re.findall(r"[a-z]+", text.lower()))
|
| 157 |
+
hindi_word_count = len(words & hindi_indicators)
|
| 158 |
+
|
| 159 |
+
# If 2+ Hindi indicator words found, classify as romanized Hindi/Hinglish
|
| 160 |
+
if hindi_word_count >= 2:
|
| 161 |
+
return "hi-en"
|
| 162 |
+
elif hindi_word_count >= 1 and len(words) <= 5:
|
| 163 |
+
return "hi-en"
|
| 164 |
+
|
| 165 |
+
# For Latin-script text that doesn't look like Hinglish, default to English.
|
| 166 |
+
# Generic language detectors are noisy on short toxic chat messages and can
|
| 167 |
+
# misclassify simple English as unrelated languages such as "sw".
|
| 168 |
+
return "en"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def is_indian_language(lang_code: str) -> bool:
|
| 172 |
+
"""Check if a language code represents an Indian language."""
|
| 173 |
+
return lang_code in {
|
| 174 |
+
"hi", "hi-en", "bn", "ta", "te", "kn", "ml",
|
| 175 |
+
"gu", "pa", "or", "indic-en",
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ── Text Cleaning ──────────────────────────────────────────────────────
|
| 180 |
+
|
| 181 |
+
def clean_text(text: str, preserve_case: bool = False) -> str:
|
| 182 |
+
"""
|
| 183 |
+
Clean and normalize text for model input.
|
| 184 |
+
|
| 185 |
+
Steps:
|
| 186 |
+
1. Unicode normalization (NFC — canonical composition)
|
| 187 |
+
2. Remove zero-width characters and control chars (preserve newlines)
|
| 188 |
+
3. Normalize whitespace
|
| 189 |
+
4. Optionally lowercase
|
| 190 |
+
|
| 191 |
+
NOTE: We do NOT remove emojis or special chars — the models handle them,
|
| 192 |
+
and they carry semantic meaning for toxicity detection.
|
| 193 |
+
"""
|
| 194 |
+
if not text:
|
| 195 |
+
return ""
|
| 196 |
+
|
| 197 |
+
# Unicode normalization
|
| 198 |
+
text = unicodedata.normalize("NFC", text)
|
| 199 |
+
|
| 200 |
+
# Remove zero-width chars and most control characters (keep \n, \t)
|
| 201 |
+
text = re.sub(r"[\u200b-\u200f\u2028-\u202f\u2060-\u2069\ufeff]", "", text)
|
| 202 |
+
|
| 203 |
+
# Normalize repeated whitespace (but preserve single newlines)
|
| 204 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 205 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 206 |
+
|
| 207 |
+
# Strip
|
| 208 |
+
text = text.strip()
|
| 209 |
+
|
| 210 |
+
if not preserve_case:
|
| 211 |
+
text = text.lower()
|
| 212 |
+
|
| 213 |
+
return text
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def normalize_for_toxicity(text: str) -> str:
|
| 217 |
+
"""
|
| 218 |
+
Additional normalization specifically for toxicity detection.
|
| 219 |
+
|
| 220 |
+
Handles common evasion techniques:
|
| 221 |
+
- L33t speak: "h4te" → "hate"
|
| 222 |
+
- Character repetition: "fuckkkk" → "fuck"
|
| 223 |
+
- Separator insertion: "f.u.c.k" → "fuck"
|
| 224 |
+
- Mixed scripts for evasion: "fuсk" (Cyrillic с) → "fuck"
|
| 225 |
+
"""
|
| 226 |
+
# Step 1: Basic cleaning
|
| 227 |
+
text = clean_text(text, preserve_case=False)
|
| 228 |
+
|
| 229 |
+
# Step 2: Reduce character repetition (keep max 2 of same char)
|
| 230 |
+
text = re.sub(r"(.)\1{2,}", r"\1\1", text)
|
| 231 |
+
|
| 232 |
+
# Step 3: Remove separators between single characters
|
| 233 |
+
# "f.u.c.k" or "f u c k" → "fuck"
|
| 234 |
+
# Only for Latin characters (don't break Devanagari)
|
| 235 |
+
text = re.sub(
|
| 236 |
+
r"(?<=[a-z])[.\-_\s](?=[a-z](?:[.\-_\s][a-z]){2,})",
|
| 237 |
+
"",
|
| 238 |
+
text,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Step 4: Common leet speak mappings
|
| 242 |
+
leet_map = {
|
| 243 |
+
"0": "o", "1": "i", "3": "e", "4": "a",
|
| 244 |
+
"5": "s", "7": "t", "8": "b", "@": "a",
|
| 245 |
+
"$": "s", "!": "i",
|
| 246 |
+
}
|
| 247 |
+
# Only apply leet substitution in words that look like leet speak
|
| 248 |
+
def _deleet(match):
|
| 249 |
+
word = match.group(0)
|
| 250 |
+
if any(c in word for c in leet_map):
|
| 251 |
+
for leet, normal in leet_map.items():
|
| 252 |
+
word = word.replace(leet, normal)
|
| 253 |
+
return word
|
| 254 |
+
|
| 255 |
+
text = re.sub(r"\b\S+\b", _deleet, text)
|
| 256 |
+
|
| 257 |
+
return text
|
requirements.txt
CHANGED
|
@@ -1,9 +1,30 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
torch=
|
| 7 |
-
transformers=
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# SafeChat ML Service — Dependencies
|
| 3 |
+
# ============================================
|
| 4 |
+
|
| 5 |
+
# --- Core ML ---
|
| 6 |
+
torch>=2.1.0
|
| 7 |
+
transformers>=4.36.0
|
| 8 |
+
tokenizers>=0.15.0
|
| 9 |
+
accelerate>=0.25.0
|
| 10 |
+
sentencepiece>=0.1.99
|
| 11 |
+
protobuf>=4.25.3
|
| 12 |
+
|
| 13 |
+
# --- FastAPI ---
|
| 14 |
+
fastapi>=0.109.0
|
| 15 |
+
uvicorn[standard]>=0.25.0
|
| 16 |
+
pydantic>=2.5.0
|
| 17 |
+
pydantic-settings>=2.1.0
|
| 18 |
+
|
| 19 |
+
# --- Language Detection ---
|
| 20 |
+
langdetect>=1.0.9
|
| 21 |
+
|
| 22 |
+
# --- Text Processing ---
|
| 23 |
+
regex>=2023.12.25
|
| 24 |
+
emoji>=2.9.0
|
| 25 |
+
|
| 26 |
+
# --- Utilities ---
|
| 27 |
+
python-dotenv>=1.0.0
|
| 28 |
+
loguru>=0.7.2
|
| 29 |
+
httpx>=0.26.0
|
| 30 |
+
numpy>=1.24.0
|