vineet88 commited on
Commit
1944d2e
·
verified ·
1 Parent(s): 16f57d9

Deploy SafeChat ML service to Hugging Face Space

Browse files
.dockerignore CHANGED
@@ -1,10 +1,24 @@
1
- .git
2
- .gitignore
3
- .env
4
- .tmp
5
- __pycache__
6
  *.pyc
7
  *.pyo
8
  *.pyd
9
- smoke_test.py
10
- deploy_to_hf_space.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.12-slim
2
 
3
- ENV PYTHONDONTWRITEBYTECODE=1
4
- ENV PYTHONUNBUFFERED=1
5
- ENV PIP_NO_CACHE_DIR=1
 
 
 
 
 
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
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
 
 
 
 
 
 
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: Context-Aware Safety ML API
3
- emoji: 🛡️
4
  colorFrom: blue
5
- colorTo: indigo
6
  sdk: docker
7
  app_port: 8000
8
  pinned: false
9
- license: mit
10
- short_description: Standalone multilingual safety inference API.
11
  ---
12
 
13
- # Standalone ML Service
14
 
15
- This folder contains a minimal ML-only inference API extracted from the larger project.
16
 
17
- It includes:
18
 
19
- - a FastAPI app for transformer inference
20
- - the local MuRIL checkpoint under `models/`
21
- - basic language detection and text normalization
22
- - CORS support for frontend clients
23
- - a smoke test for local verification
24
- - Docker metadata ready for Hugging Face Spaces
25
 
26
- ## Run locally
27
 
28
- ```powershell
29
- python -m venv .venv
30
- .venv\Scripts\activate
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
- ## Smoke test
37
 
38
- ```powershell
39
- python smoke_test.py
40
- ```
41
-
42
- ## API
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
- """Standalone ML inference service package."""
 
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
- from functools import lru_cache
 
 
 
2
  from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- from pydantic_settings import BaseSettings, SettingsConfigDict
5
 
6
- ENV_FILE = Path(__file__).resolve().parents[1] / ".env"
 
 
 
 
 
 
 
7
 
8
 
9
  class Settings(BaseSettings):
10
- model_config = SettingsConfigDict(
11
- env_file=str(ENV_FILE),
12
- env_file_encoding="utf-8",
13
- extra="ignore",
14
- )
 
 
 
15
 
16
- app_name: str = "Standalone Content Safety ML Service"
17
- app_version: str = "0.1.0"
18
- api_prefix: str = "/api/v1"
19
- model_artifact_path: str = "./models/muril-balanced-30k-v1"
20
- model_version: str = "muril-balanced-30k-v1"
21
- model_min_score: float = 0.35
22
- model_max_length: int = 256
23
- cors_allow_origins: str = (
24
- "http://localhost:3000,"
25
- "http://127.0.0.1:3000,"
26
- "http://localhost:5173,"
27
- "http://127.0.0.1:5173"
 
 
 
 
 
 
 
28
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- @property
31
- def cors_allow_origins_list(self) -> list[str]:
32
- return [item.strip() for item in self.cors_allow_origins.split(",") if item.strip()]
 
33
 
34
 
35
- @lru_cache
36
- def get_settings() -> Settings:
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 APIRouter, FastAPI
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
- settings = get_settings()
17
- model = TransformerModerationModel(
18
- artifact_path=settings.model_artifact_path,
19
- max_length=settings.model_max_length,
20
- min_score=settings.model_min_score,
21
- )
22
- model.warmup()
23
- app.state.model = model
24
- app.state.normalizer = TextNormalizer()
25
- app.state.language_detector = HeuristicLanguageDetector()
26
- app.state.settings = settings
27
- yield
28
-
29
-
30
- def create_app() -> FastAPI:
31
- settings = get_settings()
32
- app = FastAPI(
33
- title=settings.app_name,
34
- version=settings.app_version,
35
- docs_url="/docs",
36
- redoc_url="/redoc",
37
- lifespan=lifespan,
38
- )
39
- app.add_middleware(
40
- CORSMiddleware,
41
- allow_origins=settings.cors_allow_origins_list,
42
- allow_credentials=True,
43
- allow_methods=["*"],
44
- allow_headers=["*"],
45
- )
46
-
47
- router = APIRouter(prefix=settings.api_prefix)
48
-
49
- @router.get("/health", response_model=HealthResponse)
50
- async def health() -> HealthResponse:
51
- model = app.state.model
52
- app_settings = app.state.settings
53
- return HealthResponse(
54
- status="ok",
55
- model_backend=model.backend_name,
56
- model_version=app_settings.model_version,
57
- model_artifact_path=app_settings.model_artifact_path,
58
- model_loaded=model.is_loaded,
59
- )
60
-
61
- @router.post("/moderate", response_model=ModerateResponse)
62
- async def moderate(payload: ModerateRequest) -> ModerateResponse:
63
- started_at = perf_counter()
64
- normalizer = app.state.normalizer
65
- detector = app.state.language_detector
66
- model = app.state.model
67
- settings = app.state.settings
68
-
69
- normalized_text = normalizer.normalize(payload.text)
70
- language = detector.detect(normalized_text)
71
- categories = model.predict(normalized_text, payload.context)
72
- latency_ms = round((perf_counter() - started_at) * 1000, 2)
73
-
74
- return ModerateResponse(
75
- normalized_text=normalized_text,
76
- categories=categories,
77
- language=language,
78
- model_backend=model.backend_name,
79
- model_version=settings.model_version,
80
- latency_ms=latency_ms,
81
- )
82
-
83
- app.include_router(router)
84
-
85
- @app.get("/", tags=["root"])
86
- async def root():
87
- return {
88
- "service": settings.app_name,
89
- "docs": "/docs",
90
- "health": f"{settings.api_prefix}/health",
91
- "moderate": f"{settings.api_prefix}/moderate",
92
- }
93
-
94
- return app
95
-
96
-
97
- app = create_app()
 
 
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
- fastapi==0.135.2
2
- uvicorn[standard]==0.42.0
3
- pydantic==2.12.5
4
- pydantic-settings==2.13.1
5
- httpx==0.28.1
6
- torch==2.5.1
7
- transformers==4.46.3
8
- sentencepiece==0.2.1
9
- safetensors==0.7.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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