vineet88 commited on
Commit
9116a0f
·
verified ·
1 Parent(s): 3fe9930

Deploy standalone ML service

Browse files
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
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
.env.example ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ APP_NAME=Standalone Content Safety ML Service
2
+ APP_VERSION=0.1.0
3
+ API_PREFIX=/api/v1
4
+ MODEL_ARTIFACT_PATH=./models/muril-balanced-30k-v1
5
+ MODEL_VERSION=muril-balanced-30k-v1
6
+ MODEL_MIN_SCORE=0.35
7
+ MODEL_MAX_LENGTH=256
8
+ CORS_ALLOW_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]
README.md CHANGED
@@ -1,10 +1,57 @@
1
  ---
2
- title: Context Aware Safety Ml
3
- emoji: 🏆
4
  colorFrom: blue
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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`
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Standalone ML inference service package."""
app/config.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+
4
+ from pydantic import field_validator
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ ENV_FILE = Path(__file__).resolve().parents[1] / ".env"
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ model_config = SettingsConfigDict(
12
+ env_file=str(ENV_FILE),
13
+ env_file_encoding="utf-8",
14
+ extra="ignore",
15
+ )
16
+
17
+ app_name: str = "Standalone Content Safety ML Service"
18
+ app_version: str = "0.1.0"
19
+ api_prefix: str = "/api/v1"
20
+ model_artifact_path: str = "./models/muril-balanced-30k-v1"
21
+ model_version: str = "muril-balanced-30k-v1"
22
+ model_min_score: float = 0.35
23
+ model_max_length: int = 256
24
+ cors_allow_origins: list[str] = [
25
+ "http://localhost:3000",
26
+ "http://127.0.0.1:3000",
27
+ "http://localhost:5173",
28
+ "http://127.0.0.1:5173",
29
+ ]
30
+
31
+ @field_validator("cors_allow_origins", mode="before")
32
+ @classmethod
33
+ def _split_origins(cls, value):
34
+ if isinstance(value, str):
35
+ return [item.strip() for item in value.split(",") if item.strip()]
36
+ return value
37
+
38
+
39
+ @lru_cache
40
+ def get_settings() -> Settings:
41
+ return Settings()
app/language.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unicodedata
2
+ from collections import Counter
3
+
4
+ from app.schemas import LanguageProfile, LanguageSignal, ScriptRatio
5
+
6
+
7
+ class HeuristicLanguageDetector:
8
+ roman_hindi_markers = {
9
+ "hai",
10
+ "nahi",
11
+ "nahin",
12
+ "tum",
13
+ "aap",
14
+ "mera",
15
+ "meri",
16
+ "kya",
17
+ "kyu",
18
+ "kyun",
19
+ "mat",
20
+ "kar",
21
+ "kr",
22
+ "bhai",
23
+ "yaar",
24
+ "acha",
25
+ "accha",
26
+ "bakwas",
27
+ "bewakoof",
28
+ }
29
+
30
+ def detect(self, text: str) -> LanguageProfile:
31
+ script_counts = Counter()
32
+
33
+ for char in text:
34
+ script_name = self._script_name(char)
35
+ if script_name is not None:
36
+ script_counts[script_name] += 1
37
+
38
+ total = sum(script_counts.values())
39
+ scripts = []
40
+ if total:
41
+ scripts = [
42
+ ScriptRatio(name=name, ratio=round(count / total, 3))
43
+ for name, count in script_counts.most_common()
44
+ ]
45
+
46
+ lowered_tokens = {token.strip(".,!?;:()[]{}\"'").lower() for token in text.split()}
47
+ roman_hindi_hits = len(self.roman_hindi_markers.intersection(lowered_tokens))
48
+
49
+ has_latin = script_counts["latin"] > 0
50
+ has_devanagari = script_counts["devanagari"] > 0
51
+ has_other_indic = script_counts["indic_other"] > 0
52
+
53
+ if has_devanagari and has_latin:
54
+ return LanguageProfile(
55
+ primary_language="hinglish",
56
+ code_mixed=True,
57
+ scripts=scripts,
58
+ candidates=[
59
+ LanguageSignal(name="hinglish", confidence=0.9),
60
+ LanguageSignal(name="hindi", confidence=0.72),
61
+ LanguageSignal(name="english", confidence=0.63),
62
+ ],
63
+ )
64
+
65
+ if has_devanagari:
66
+ return LanguageProfile(
67
+ primary_language="hindi",
68
+ code_mixed=False,
69
+ scripts=scripts,
70
+ candidates=[
71
+ LanguageSignal(name="hindi", confidence=0.92),
72
+ LanguageSignal(name="hinglish", confidence=0.28),
73
+ ],
74
+ )
75
+
76
+ if has_latin and roman_hindi_hits >= 2:
77
+ return LanguageProfile(
78
+ primary_language="hinglish",
79
+ code_mixed=True,
80
+ scripts=scripts,
81
+ candidates=[
82
+ LanguageSignal(name="hinglish", confidence=0.82),
83
+ LanguageSignal(name="english", confidence=0.58),
84
+ ],
85
+ )
86
+
87
+ if has_latin:
88
+ return LanguageProfile(
89
+ primary_language="english",
90
+ code_mixed=False,
91
+ scripts=scripts,
92
+ candidates=[
93
+ LanguageSignal(name="english", confidence=0.9),
94
+ LanguageSignal(name="hinglish", confidence=0.25),
95
+ ],
96
+ )
97
+
98
+ if has_other_indic:
99
+ return LanguageProfile(
100
+ primary_language="indic_other",
101
+ code_mixed=False,
102
+ scripts=scripts,
103
+ candidates=[LanguageSignal(name="indic_other", confidence=0.8)],
104
+ )
105
+
106
+ return LanguageProfile(
107
+ primary_language="unknown",
108
+ code_mixed=False,
109
+ scripts=scripts,
110
+ candidates=[LanguageSignal(name="unknown", confidence=0.4)],
111
+ )
112
+
113
+ def _script_name(self, char: str) -> str | None:
114
+ if not char.isalpha():
115
+ return None
116
+
117
+ name = unicodedata.name(char, "")
118
+ if "LATIN" in name:
119
+ return "latin"
120
+ if "DEVANAGARI" in name:
121
+ return "devanagari"
122
+ if any(
123
+ block in name
124
+ for block in (
125
+ "BENGALI",
126
+ "GURMUKHI",
127
+ "GUJARATI",
128
+ "ORIYA",
129
+ "TAMIL",
130
+ "TELUGU",
131
+ "KANNADA",
132
+ "MALAYALAM",
133
+ )
134
+ ):
135
+ return "indic_other"
136
+ return "other"
app/main.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
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()
app/model.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import cached_property
5
+
6
+ from app.schemas import CategoryName, CategoryScore, ConversationTurn
7
+
8
+
9
+ @dataclass
10
+ class TransformerModerationModel:
11
+ artifact_path: str
12
+ max_length: int = 256
13
+ min_score: float = 0.35
14
+ backend_name: str = "transformer"
15
+
16
+ @cached_property
17
+ def tokenizer(self):
18
+ from transformers import AutoTokenizer
19
+
20
+ return AutoTokenizer.from_pretrained(self.artifact_path)
21
+
22
+ @cached_property
23
+ def model(self):
24
+ from transformers import AutoModelForSequenceClassification
25
+
26
+ return AutoModelForSequenceClassification.from_pretrained(self.artifact_path)
27
+
28
+ @property
29
+ def is_loaded(self) -> bool:
30
+ return "model" in self.__dict__ and "tokenizer" in self.__dict__
31
+
32
+ def warmup(self) -> None:
33
+ _ = self.tokenizer
34
+ _ = self.model
35
+
36
+ def predict(
37
+ self,
38
+ text: str,
39
+ context: list[ConversationTurn],
40
+ ) -> list[CategoryScore]:
41
+ import torch
42
+
43
+ packed_text = self._pack_input(text=text, context=context)
44
+ encoded = self.tokenizer(
45
+ packed_text,
46
+ truncation=True,
47
+ padding=False,
48
+ max_length=self.max_length,
49
+ return_tensors="pt",
50
+ )
51
+ with torch.no_grad():
52
+ logits = self.model(**encoded).logits[0]
53
+ probabilities = torch.sigmoid(logits).tolist()
54
+
55
+ results: list[CategoryScore] = []
56
+ id_to_label = getattr(self.model.config, "id2label", {})
57
+ for index, score in enumerate(probabilities):
58
+ label = id_to_label.get(index, str(index))
59
+ normalized_label = normalize_label(label)
60
+ if normalized_label is None or score < self.min_score:
61
+ continue
62
+ results.append(
63
+ CategoryScore(
64
+ name=normalized_label,
65
+ score=round(float(score), 4),
66
+ source="transformer",
67
+ rationale=f"Model logits crossed threshold for {normalized_label.value}.",
68
+ )
69
+ )
70
+ return sorted(results, key=lambda category: category.score, reverse=True)
71
+
72
+ def _pack_input(
73
+ self,
74
+ text: str,
75
+ context: list[ConversationTurn],
76
+ ) -> str:
77
+ turns = []
78
+ for offset, turn in enumerate(context[-5:], start=1):
79
+ turns.append(f"[TURN-{offset}] {turn.role.value}: {turn.text}")
80
+ turns.append(f"[CURRENT] user: {text}")
81
+ return "\n".join(turns)
82
+
83
+
84
+ def normalize_label(label: str) -> CategoryName | None:
85
+ normalized = (
86
+ label.strip()
87
+ .lower()
88
+ .replace("-", "_")
89
+ .replace("/", "_")
90
+ .replace(" ", "_")
91
+ )
92
+ mapping = {
93
+ "harassment_or_insult": CategoryName.HARASSMENT_OR_INSULT,
94
+ "insult": CategoryName.HARASSMENT_OR_INSULT,
95
+ "harassment": CategoryName.HARASSMENT_OR_INSULT,
96
+ "toxic": CategoryName.HARASSMENT_OR_INSULT,
97
+ "severe_toxic": CategoryName.HARASSMENT_OR_INSULT,
98
+ "threat_or_violence": CategoryName.THREAT_OR_VIOLENCE,
99
+ "threat": CategoryName.THREAT_OR_VIOLENCE,
100
+ "violence": CategoryName.THREAT_OR_VIOLENCE,
101
+ "hate": CategoryName.HATE,
102
+ "identity_hate": CategoryName.HATE,
103
+ "self_harm": CategoryName.SELF_HARM,
104
+ "sexual_explicit": CategoryName.SEXUAL_EXPLICIT,
105
+ "sexual": CategoryName.SEXUAL_EXPLICIT,
106
+ "obscene": CategoryName.PROFANITY,
107
+ "profanity": CategoryName.PROFANITY,
108
+ "spam": CategoryName.SPAM,
109
+ }
110
+ return mapping.get(normalized)
app/normalization.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import unicodedata
3
+
4
+
5
+ class TextNormalizer:
6
+ url_pattern = re.compile(r"https?://\S+|www\.\S+", re.IGNORECASE)
7
+ mention_pattern = re.compile(r"@\w+")
8
+ whitespace_pattern = re.compile(r"\s+")
9
+ repeated_latin_pattern = re.compile(r"([A-Za-z])\1{2,}")
10
+ zero_width_pattern = re.compile(r"[\u200b-\u200f\u2060\ufeff]")
11
+ repeated_punctuation_pattern = re.compile(r"([!?.,])\1{2,}")
12
+
13
+ def normalize(self, text: str) -> str:
14
+ normalized = unicodedata.normalize("NFKC", text).strip()
15
+ normalized = self.zero_width_pattern.sub("", normalized)
16
+ normalized = self.url_pattern.sub("<URL>", normalized)
17
+ normalized = self.mention_pattern.sub("<USER>", normalized)
18
+ normalized = self.repeated_latin_pattern.sub(r"\1\1", normalized)
19
+ normalized = self.repeated_punctuation_pattern.sub(r"\1\1", normalized)
20
+ normalized = self.whitespace_pattern.sub(" ", normalized)
21
+ return normalized
app/schemas.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import StrEnum
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class CategoryName(StrEnum):
7
+ HARASSMENT_OR_INSULT = "harassment_or_insult"
8
+ THREAT_OR_VIOLENCE = "threat_or_violence"
9
+ HATE = "hate"
10
+ SELF_HARM = "self_harm"
11
+ SEXUAL_EXPLICIT = "sexual_explicit"
12
+ PROFANITY = "profanity"
13
+ SPAM = "spam"
14
+
15
+
16
+ class ConversationRole(StrEnum):
17
+ USER = "user"
18
+ ASSISTANT = "assistant"
19
+ MODERATOR = "moderator"
20
+ SYSTEM = "system"
21
+
22
+
23
+ class ConversationTurn(BaseModel):
24
+ role: ConversationRole
25
+ text: str = Field(min_length=1, max_length=2000)
26
+ user_id: str | None = None
27
+
28
+
29
+ class CategoryScore(BaseModel):
30
+ name: CategoryName
31
+ score: float = Field(ge=0.0, le=1.0)
32
+ source: str
33
+ rationale: str
34
+
35
+
36
+ class ScriptRatio(BaseModel):
37
+ name: str
38
+ ratio: float = Field(ge=0.0, le=1.0)
39
+
40
+
41
+ class LanguageSignal(BaseModel):
42
+ name: str
43
+ confidence: float = Field(ge=0.0, le=1.0)
44
+
45
+
46
+ class LanguageProfile(BaseModel):
47
+ primary_language: str
48
+ code_mixed: bool
49
+ scripts: list[ScriptRatio] = Field(default_factory=list)
50
+ candidates: list[LanguageSignal] = Field(default_factory=list)
51
+
52
+
53
+ class ModerateRequest(BaseModel):
54
+ text: str = Field(min_length=1, max_length=4000)
55
+ context: list[ConversationTurn] = Field(default_factory=list, max_length=10)
56
+
57
+
58
+ class ModerateResponse(BaseModel):
59
+ normalized_text: str
60
+ categories: list[CategoryScore] = Field(default_factory=list)
61
+ language: LanguageProfile
62
+ model_backend: str
63
+ model_version: str
64
+ latency_ms: float = Field(ge=0.0)
65
+
66
+
67
+ class HealthResponse(BaseModel):
68
+ status: str
69
+ model_backend: str
70
+ model_version: str
71
+ model_artifact_path: str
72
+ model_loaded: bool
deploy_to_hf_space.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from huggingface_hub import HfApi
7
+
8
+
9
+ SPACE_DIR = Path(__file__).resolve().parent
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(description="Deploy the standalone ML service to Hugging Face Spaces.")
14
+ parser.add_argument("--repo-id", required=True, help="Space repo id, for example: username/space-name")
15
+ parser.add_argument("--token", required=True, help="Hugging Face access token")
16
+ parser.add_argument("--private", action="store_true", help="Create the Space as private")
17
+ return parser
18
+
19
+
20
+ def main() -> None:
21
+ args = build_parser().parse_args()
22
+ api = HfApi(token=args.token)
23
+
24
+ create_kwargs = {
25
+ "repo_id": args.repo_id,
26
+ "token": args.token,
27
+ "repo_type": "space",
28
+ "space_sdk": "docker",
29
+ "private": args.private,
30
+ "exist_ok": True,
31
+ }
32
+
33
+ api.create_repo(**create_kwargs)
34
+
35
+ ignore_patterns = [
36
+ ".env",
37
+ ".tmp/*",
38
+ "__pycache__/*",
39
+ "*.pyc",
40
+ "*.pyo",
41
+ "*.pyd",
42
+ ]
43
+
44
+ api.upload_folder(
45
+ repo_id=args.repo_id,
46
+ folder_path=str(SPACE_DIR),
47
+ repo_type="space",
48
+ token=args.token,
49
+ commit_message="Deploy standalone ML service",
50
+ ignore_patterns=ignore_patterns,
51
+ )
52
+
53
+ print(f"Deployment uploaded to https://huggingface.co/spaces/{args.repo_id}")
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
models/muril-balanced-30k-v1/config.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_name_or_path": "google/muril-base-cased",
3
+ "architectures": [
4
+ "BertForSequenceClassification"
5
+ ],
6
+ "attention_probs_dropout_prob": 0.1,
7
+ "classifier_dropout": null,
8
+ "embedding_size": 768,
9
+ "hidden_act": "gelu",
10
+ "hidden_dropout_prob": 0.1,
11
+ "hidden_size": 768,
12
+ "id2label": {
13
+ "0": "toxic",
14
+ "1": "severe_toxic",
15
+ "2": "obscene",
16
+ "3": "threat",
17
+ "4": "insult",
18
+ "5": "identity_hate"
19
+ },
20
+ "initializer_range": 0.02,
21
+ "intermediate_size": 3072,
22
+ "label2id": {
23
+ "identity_hate": 5,
24
+ "insult": 4,
25
+ "obscene": 2,
26
+ "severe_toxic": 1,
27
+ "threat": 3,
28
+ "toxic": 0
29
+ },
30
+ "layer_norm_eps": 1e-12,
31
+ "max_position_embeddings": 512,
32
+ "model_type": "bert",
33
+ "num_attention_heads": 12,
34
+ "num_hidden_layers": 12,
35
+ "pad_token_id": 0,
36
+ "position_embedding_type": "absolute",
37
+ "problem_type": "multi_label_classification",
38
+ "torch_dtype": "float32",
39
+ "transformers_version": "4.40.0",
40
+ "type_vocab_size": 2,
41
+ "use_cache": true,
42
+ "vocab_size": 197285
43
+ }
models/muril-balanced-30k-v1/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6749cec301b96e2238f2d0bfc980a24eb70841e98c198f53feaae3972e72a2dd
3
+ size 950266896
models/muril-balanced-30k-v1/special_tokens_map.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "cls_token": "[CLS]",
3
+ "mask_token": "[MASK]",
4
+ "pad_token": "[PAD]",
5
+ "sep_token": "[SEP]",
6
+ "unk_token": "[UNK]"
7
+ }
models/muril-balanced-30k-v1/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
models/muril-balanced-30k-v1/tokenizer_config.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "added_tokens_decoder": {
3
+ "0": {
4
+ "content": "[PAD]",
5
+ "lstrip": false,
6
+ "normalized": false,
7
+ "rstrip": false,
8
+ "single_word": false,
9
+ "special": true
10
+ },
11
+ "100": {
12
+ "content": "[UNK]",
13
+ "lstrip": false,
14
+ "normalized": false,
15
+ "rstrip": false,
16
+ "single_word": false,
17
+ "special": true
18
+ },
19
+ "103": {
20
+ "content": "[MASK]",
21
+ "lstrip": false,
22
+ "normalized": false,
23
+ "rstrip": false,
24
+ "single_word": false,
25
+ "special": true
26
+ },
27
+ "104": {
28
+ "content": "[CLS]",
29
+ "lstrip": false,
30
+ "normalized": false,
31
+ "rstrip": false,
32
+ "single_word": false,
33
+ "special": true
34
+ },
35
+ "105": {
36
+ "content": "[SEP]",
37
+ "lstrip": false,
38
+ "normalized": false,
39
+ "rstrip": false,
40
+ "single_word": false,
41
+ "special": true
42
+ }
43
+ },
44
+ "clean_up_tokenization_spaces": true,
45
+ "cls_token": "[CLS]",
46
+ "do_basic_tokenize": true,
47
+ "do_lower_case": false,
48
+ "lowercase": false,
49
+ "mask_token": "[MASK]",
50
+ "model_max_length": 512,
51
+ "never_split": null,
52
+ "pad_token": "[PAD]",
53
+ "sep_token": "[SEP]",
54
+ "strip_accents": false,
55
+ "tokenize_chinese_chars": true,
56
+ "tokenizer_class": "BertTokenizer",
57
+ "unk_token": "[UNK]"
58
+ }
models/muril-balanced-30k-v1/vocab.txt ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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
smoke_test.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from fastapi.testclient import TestClient
5
+
6
+
7
+ def main() -> None:
8
+ project_root = Path(__file__).resolve().parent
9
+ os.environ["MODEL_ARTIFACT_PATH"] = str(project_root / "models" / "muril-balanced-30k-v1")
10
+
11
+ from app.config import get_settings
12
+ from app.main import create_app
13
+
14
+ get_settings.cache_clear()
15
+ app = create_app()
16
+
17
+ with TestClient(app) as client:
18
+ health = client.get("/api/v1/health")
19
+ assert health.status_code == 200, health.text
20
+
21
+ response = client.post(
22
+ "/api/v1/moderate",
23
+ json={
24
+ "text": "You are an idiot and I will hurt you",
25
+ "context": [],
26
+ },
27
+ )
28
+ assert response.status_code == 200, response.text
29
+ payload = response.json()
30
+ assert payload["model_backend"] == "transformer"
31
+ assert payload["normalized_text"]
32
+ assert payload["latency_ms"] >= 0
33
+
34
+ print("Standalone ML service smoke test passed.")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()