Hamdy005 commited on
Commit
00c3bab
Β·
1 Parent(s): d2ce5f7

refactor: add NSFW and gibbreish words filtering

Browse files
Files changed (6) hide show
  1. config.py +10 -0
  2. main.py +7 -0
  3. materials/routes.py +3 -3
  4. materials/validator.py +168 -45
  5. rag/batch_workers.py +74 -1
  6. rag/schemas.py +9 -0
config.py CHANGED
@@ -32,6 +32,16 @@ class Settings:
32
  for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
33
  if origin.strip()
34
  ]
 
 
 
 
 
 
 
 
 
 
35
 
36
 
37
  @lru_cache()
 
32
  for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
33
  if origin.strip()
34
  ]
35
+ local_arabic_nsfw_words: list[str] = [
36
+ w.strip()
37
+ for w in os.getenv("LOCAL_ARABIC_NSFW_WORDS", "").split(",")
38
+ if w.strip()
39
+ ]
40
+ local_nsfw_words: list[str] = [
41
+ w.strip()
42
+ for w in os.getenv("LOCAL_NSFW_WORDS", "").split(",")
43
+ if w.strip()
44
+ ]
45
 
46
 
47
  @lru_cache()
main.py CHANGED
@@ -51,6 +51,13 @@ async def lifespan(app: FastAPI):
51
  except Exception as e:
52
  logger.warning(f"Embedder failed to load: {e}")
53
 
 
 
 
 
 
 
 
54
  from src.rag.batch_workers import start_workers
55
  start_workers()
56
 
 
51
  except Exception as e:
52
  logger.warning(f"Embedder failed to load: {e}")
53
 
54
+ try:
55
+ from src.materials.validator import warmup_validation_models
56
+ warmup_validation_models()
57
+ logger.info("Validation models loaded and warmed up successfully.")
58
+ except Exception as e:
59
+ logger.warning(f"Validation models failed to load: {e}")
60
+
61
  from src.rag.batch_workers import start_workers
62
  start_workers()
63
 
materials/routes.py CHANGED
@@ -233,7 +233,7 @@ async def rename_material_endpoint(
233
  return {"status": "ok"}
234
 
235
 
236
- from src.materials.validator import validate_topic_input
237
 
238
  @router.post("/topic")
239
  async def create_topic(
@@ -244,8 +244,8 @@ async def create_topic(
244
  if not topic_str:
245
  raise HTTPException(400, "Topic title cannot be empty")
246
 
247
- # NSFW and gibberish validation
248
- validation_res = validate_topic_input(topic_str)
249
  if validation_res != "ALLOWED":
250
  raise HTTPException(400, validation_res)
251
 
 
233
  return {"status": "ok"}
234
 
235
 
236
+ from src.materials.validator import validate_topic_input_async
237
 
238
  @router.post("/topic")
239
  async def create_topic(
 
244
  if not topic_str:
245
  raise HTTPException(400, "Topic title cannot be empty")
246
 
247
+ # NSFW and gibberish validation using batch worker
248
+ validation_res = await validate_topic_input_async(topic_str)
249
  if validation_res != "ALLOWED":
250
  raise HTTPException(400, validation_res)
251
 
materials/validator.py CHANGED
@@ -5,12 +5,11 @@ from src.config import settings
5
 
6
  logger = logging.getLogger(__name__)
7
 
8
- # Basic English NSFW word list to catch obvious cases instantly
9
- LOCAL_NSFW_WORDS = {
10
- "porn", "sex", "nude", "bitch", "fuck", "asshole", "cunt", "dick",
11
- "pussy", "nigger", "faggot", "bastard", "slut", "whore", "cock",
12
- "boob", "tit", "vagina", "penis", "clitoris"
13
- }
14
 
15
  def is_local_gibberish(text: str) -> bool:
16
  text_clean = text.strip().lower()
@@ -42,13 +41,25 @@ def is_local_gibberish(text: str) -> bool:
42
  def is_local_nsfw(text: str) -> bool:
43
  text_clean = text.strip().lower()
44
 
45
- # Substring checks for high-signal NSFW roots
 
 
 
 
 
 
 
 
 
 
 
 
46
  high_signal_substrings = {"porn", "nude", "sex", "vagina", "penis", "clitoris"}
47
  for root in high_signal_substrings:
48
  if root in text_clean:
49
  return True
50
 
51
- # Check for direct matches or common word boundary matches
52
  for word in LOCAL_NSFW_WORDS:
53
  # If it was already checked as a high-signal substring, skip
54
  if word in high_signal_substrings:
@@ -78,50 +89,162 @@ def is_local_nsfw(text: str) -> bool:
78
 
79
  return False
80
 
81
- def validate_topic_input(topic: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  """
83
- Validates a topic string.
 
 
 
 
84
  Returns:
85
- str: "ALLOWED" if the topic is valid, or a error message string if blocked.
86
  """
87
- # 1. Quick Local Checks
88
- if is_local_nsfw(topic):
89
- return "NSFW words, profanity, or slang are not allowed."
 
 
 
 
 
 
 
 
 
 
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  if is_local_gibberish(topic):
92
- return "Topic appears to be gibberish or meaningless text."
 
 
 
93
 
94
- # 2. LLM Check for multi-lingual and advanced cases
 
 
 
 
95
  try:
96
- llm = get_llm()
97
- prompt = (
98
- "You are a content filter for an educational application.\n"
99
- f"Analyze the topic: \"{topic}\"\n\n"
100
- "Determine if it contains:\n"
101
- "1. NSFW content, profanity, swearing, slang insults, sexual references, or pornographic terms in ANY language.\n"
102
- "2. Gibberish, random sequences of characters/numbers (e.g., \"12321321\", \"asdsaba\", \"aaaabbbb\", \"esaejsaioejasoi\").\n"
103
- "3. Completely meaningless or troll input.\n\n"
104
- "Respond in one of these two formats:\n"
105
- "- If allowed: ALLOWED\n"
106
- "- If blocked: BLOCKED: <reason in English>\n"
107
- "Do not output any markdown, tags, or extra words. Just the raw text."
108
- )
109
 
110
- response = llm.invoke(prompt)
111
- result = response.content.strip()
 
 
 
112
 
113
- if result == "ALLOWED":
114
- return "ALLOWED"
115
- elif result.startswith("BLOCKED:"):
116
- reason = result.replace("BLOCKED:", "").strip()
117
- return reason or "Topic is not allowed."
118
- else:
119
- # Fallback if the LLM output structure was unexpected
120
- if "blocked" in result.lower():
121
- return "Topic contains content that is not allowed."
122
- return "ALLOWED"
123
-
124
  except Exception as e:
125
- logger.error(f"LLM topic validation failed: {e}", exc_info=True)
126
- # In case of API failure, fall back to allowing if it passed local checks
127
- return "ALLOWED"
 
5
 
6
  logger = logging.getLogger(__name__)
7
 
8
+ # Load English NSFW word list from config settings (kept out of committed code)
9
+ LOCAL_NSFW_WORDS = set(settings.local_nsfw_words)
10
+
11
+ # Load Arabic NSFW word list from config settings (kept out of committed code)
12
+ LOCAL_ARABIC_NSFW_WORDS = set(settings.local_arabic_nsfw_words)
 
13
 
14
  def is_local_gibberish(text: str) -> bool:
15
  text_clean = text.strip().lower()
 
41
  def is_local_nsfw(text: str) -> bool:
42
  text_clean = text.strip().lower()
43
 
44
+ # Check Arabic NSFW words
45
+ words = text_clean.split()
46
+ for w in words:
47
+ if w in LOCAL_ARABIC_NSFW_WORDS:
48
+ return True
49
+
50
+ # Substring checks for high-signal Arabic NSFW roots
51
+ arabic_substrings = {"Ψ³ΩƒΨ³", "Ψ¨ΩˆΨ±Ω†", "Ψ΄Ψ±Ω…ΩˆΨ·", "Ω…Ω†ΩŠΩˆΩƒ", "Ω‚Ψ­Ψ¨Ψ©", "Ω‚Ψ­Ψ¨Ω‡", "Ω…ΨͺΩ†Ψ§Ωƒ"}
52
+ for root in arabic_substrings:
53
+ if root in text_clean:
54
+ return True
55
+
56
+ # Substring checks for high-signal English NSFW roots
57
  high_signal_substrings = {"porn", "nude", "sex", "vagina", "penis", "clitoris"}
58
  for root in high_signal_substrings:
59
  if root in text_clean:
60
  return True
61
 
62
+ # Check for direct matches or common word boundary matches for English
63
  for word in LOCAL_NSFW_WORDS:
64
  # If it was already checked as a high-signal substring, skip
65
  if word in high_signal_substrings:
 
89
 
90
  return False
91
 
92
+ from transformers import pipeline
93
+
94
+ _translator = None
95
+ _gibberish_detector = None
96
+ _nsfw_classifier = None
97
+
98
+ def get_translator():
99
+ global _translator
100
+ if _translator is None:
101
+ logger.info("Loading translation model Helsinki-NLP/opus-mt-ar-en...")
102
+ _translator = pipeline("translation", model="Helsinki-NLP/opus-mt-ar-en", device="cpu")
103
+ return _translator
104
+
105
+ def get_gibberish_detector():
106
+ global _gibberish_detector
107
+ if _gibberish_detector is None:
108
+ logger.info("Loading gibberish detector model madhurjindal/autonlp-Gibberish-Detector-492513457...")
109
+ _gibberish_detector = pipeline("text-classification", model="madhurjindal/autonlp-Gibberish-Detector-492513457", device="cpu")
110
+ return _gibberish_detector
111
+
112
+ def get_nsfw_classifier():
113
+ global _nsfw_classifier
114
+ if _nsfw_classifier is None:
115
+ logger.info("Loading NSFW text classifier model michelleli99/NSFW_text_classifier...")
116
+ _nsfw_classifier = pipeline("text-classification", model="michelleli99/NSFW_text_classifier", device="cpu")
117
+ return _nsfw_classifier
118
+
119
+ def is_arabic(text: str) -> bool:
120
+ return bool(re.search(r"[\u0600-\u06FF]", text))
121
+
122
+ def validate_topics_batch(texts: list[str]) -> list[str]:
123
  """
124
+ Validates a batch of topics.
125
+ For each topic text:
126
+ 1. Detect if Arabic. If Arabic, translate it to English.
127
+ 2. Run the gibberish detector model.
128
+ 3. Run the NSFW text classifier model.
129
  Returns:
130
+ A list of results: "ALLOWED", "gibberish words", or "not safe for work words".
131
  """
132
+ logger.info(f"Validating batch of {len(texts)} topics...")
133
+ results = ["ALLOWED"] * len(texts)
134
+
135
+ # 1. Quick local pre-checks
136
+ for i, text in enumerate(texts):
137
+ if is_local_nsfw(text):
138
+ results[i] = "not safe for work words"
139
+ elif is_local_gibberish(text):
140
+ results[i] = "gibberish words"
141
+
142
+ # Collect indices of texts that passed local checks and need ML model check
143
+ pending_indices = [i for i, res in enumerate(results) if res == "ALLOWED"]
144
+ if not pending_indices:
145
+ return results
146
 
147
+ processed_texts = [texts[i] for i in pending_indices]
148
+
149
+ # Translate Arabic inputs to English
150
+ arabic_indices = [i for i, idx in enumerate(pending_indices) if is_arabic(processed_texts[i])]
151
+ if arabic_indices:
152
+ arabic_texts = [processed_texts[i] for i in arabic_indices]
153
+ try:
154
+ translator = get_translator()
155
+ translations = translator(arabic_texts)
156
+ for idx, translation in zip(arabic_indices, translations):
157
+ translated_text = translation.get("translation_text", "").strip()
158
+ processed_texts[idx] = translated_text
159
+ logger.info(f"Translated Arabic topic '{texts[pending_indices[idx]]}' to '{translated_text}'")
160
+
161
+ # Check translated text against local English NSFW list
162
+ if is_local_nsfw(translated_text):
163
+ results[pending_indices[idx]] = "not safe for work words"
164
+ except Exception as e:
165
+ logger.error(f"Batch translation failed: {e}", exc_info=True)
166
+
167
+ # Run Gibberish detector on all processed texts
168
+ try:
169
+ gibberish_detector = get_gibberish_detector()
170
+ gibberish_preds = gibberish_detector(processed_texts)
171
+ for i, pred in enumerate(gibberish_preds):
172
+ actual_idx = pending_indices[i]
173
+ # Only set if it hasn't already been flagged by translation local NSFW check
174
+ if results[actual_idx] == "ALLOWED":
175
+ label = pred.get("label", "").lower()
176
+ if label in ("noise", "word salad"):
177
+ results[actual_idx] = "gibberish words"
178
+ except Exception as e:
179
+ logger.error(f"Batch gibberish detection failed: {e}", exc_info=True)
180
+
181
+ # Run NSFW text classifier on all processed texts
182
+ try:
183
+ nsfw_classifier = get_nsfw_classifier()
184
+ nsfw_preds = nsfw_classifier(processed_texts)
185
+ for i, pred in enumerate(nsfw_preds):
186
+ actual_idx = pending_indices[i]
187
+ label = pred.get("label", "").lower()
188
+ if "nsfw" in label:
189
+ # NSFW always takes precedence over gibberish detection
190
+ results[actual_idx] = "not safe for work words"
191
+ except Exception as e:
192
+ logger.error(f"Batch NSFW classification failed: {e}", exc_info=True)
193
+
194
+ return results
195
+
196
+ async def validate_topic_input_async(topic: str) -> str:
197
+ """
198
+ Asynchronously validates a topic string by routing it through the validation batch queue.
199
+ """
200
+ import uuid
201
+ from src.rag.batch_workers import validation_queue, validation_job_store
202
+ from src.rag.schemas import ValidationJob
203
+
204
+ job = ValidationJob(job_id=str(uuid.uuid4()), text=topic)
205
+ validation_job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
206
+ await validation_queue.put(job)
207
+ await job.done.wait()
208
+
209
+ entry = validation_job_store.pop(job.job_id)
210
+ if entry["status"] == "error":
211
+ raise RuntimeError(f"Validation failed: {entry['error']}")
212
+
213
+ return entry["result"]
214
+
215
+ def validate_topic_input(topic: str) -> str:
216
+ """
217
+ Synchronous validation fallback (e.g. for non-async contexts).
218
+ """
219
+ if is_local_nsfw(topic):
220
+ return "not safe for work words"
221
  if is_local_gibberish(topic):
222
+ return "gibberish words"
223
+
224
+ res = validate_topics_batch([topic])
225
+ return res[0]
226
 
227
+ def warmup_validation_models():
228
+ """
229
+ Dummy forward passes to warm up translation, gibberish detection, and NSFW classification models.
230
+ """
231
+ logger.info("Warming up translation and text classification models...")
232
  try:
233
+ translator = get_translator()
234
+ translator("Ω…Ψ±Ψ­Ψ¨Ψ§")
235
+ except Exception as e:
236
+ logger.warning(f"Translation warmup failed: {e}")
 
 
 
 
 
 
 
 
 
237
 
238
+ try:
239
+ gibberish = get_gibberish_detector()
240
+ gibberish("hello")
241
+ except Exception as e:
242
+ logger.warning(f"Gibberish detector warmup failed: {e}")
243
 
244
+ try:
245
+ nsfw = get_nsfw_classifier()
246
+ nsfw("hello")
 
 
 
 
 
 
 
 
247
  except Exception as e:
248
+ logger.warning(f"NSFW classifier warmup failed: {e}")
249
+ logger.info("Validation models warmup complete.")
250
+
rag/batch_workers.py CHANGED
@@ -64,6 +64,9 @@ def is_request_in_flight() -> bool:
64
 
65
  embedding_queue: asyncio.Queue[EmbeddingJob] = asyncio.Queue()
66
 
 
 
 
67
 
68
  # ═══════════════════════ Workers ════════════════════════
69
 
@@ -150,6 +153,72 @@ async def embedding_worker():
150
  set_request_in_flight(False)
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  # ═══════════════════════ Warmup Loop ════════════════════════
154
 
155
 
@@ -161,6 +230,7 @@ async def _warmup_loop():
161
  Skipped entirely if a real request is in flight.
162
  """
163
  from src.rag.rag import warmup_embedder
 
164
 
165
  loop = asyncio.get_event_loop()
166
 
@@ -171,6 +241,7 @@ async def _warmup_loop():
171
  t0 = time.monotonic()
172
  try:
173
  await loop.run_in_executor(None, warmup_embedder)
 
174
  except Exception as e:
175
  logger.warning(f"Warmup cycle error (non-fatal): {e}")
176
  continue
@@ -188,6 +259,7 @@ def start_workers():
188
  Launch all async worker coroutines. Call once during app startup.
189
 
190
  - 1 embedding worker (batched SentenceTransformer inference)
 
191
  - 1 warmup loop (keeps OpenMP threads alive)
192
  """
193
  global _workers_started
@@ -197,6 +269,7 @@ def start_workers():
197
 
198
  # Use only 1 worker to save RAM on this environment
199
  asyncio.create_task(embedding_worker(), name="embedding_worker_0")
 
200
  asyncio.create_task(_warmup_loop(), name="warmup_loop")
201
 
202
- logger.info("Embedding batch workers started (1 worker + warmup loop)")
 
64
 
65
  embedding_queue: asyncio.Queue[EmbeddingJob] = asyncio.Queue()
66
 
67
+ validation_queue: asyncio.Queue[Any] = asyncio.Queue()
68
+ validation_job_store: dict[str, dict[str, Any]] = {}
69
+
70
 
71
  # ═══════════════════════ Workers ════════════════════════
72
 
 
153
  set_request_in_flight(False)
154
 
155
 
156
+ async def validation_worker():
157
+ """
158
+ Drains up to {BATCH_MAX_SIZE} validation jobs every {BATCH_WINDOW_S * 1000:.0f}ms.
159
+ Runs batch validation using validate_topics_batch.
160
+ """
161
+ from src.materials.validator import validate_topics_batch
162
+
163
+ loop = asyncio.get_event_loop()
164
+
165
+ while True:
166
+ # Wait for at least one job
167
+ first_job = await validation_queue.get()
168
+ batch = [first_job]
169
+
170
+ # Collect up to 7 more within the time window
171
+ deadline = loop.time() + BATCH_WINDOW_S
172
+ while len(batch) < BATCH_MAX_SIZE:
173
+ remaining = deadline - loop.time()
174
+ if remaining <= 0:
175
+ break
176
+ try:
177
+ job = await asyncio.wait_for(validation_queue.get(), timeout=remaining)
178
+ batch.append(job)
179
+ except asyncio.TimeoutError:
180
+ break
181
+
182
+ try:
183
+ set_request_in_flight(True)
184
+
185
+ # Gather all texts from all jobs in the batch
186
+ all_texts = [job.text for job in batch]
187
+
188
+ # Single batch run for the entire batch
189
+ results = await loop.run_in_executor(
190
+ None, validate_topics_batch, all_texts
191
+ )
192
+
193
+ # Distribute results back to individual jobs
194
+ for i, job in enumerate(batch):
195
+ job_result = results[i]
196
+
197
+ if job.job_id not in validation_job_store:
198
+ validation_job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
199
+
200
+ validation_job_store[job.job_id].update({
201
+ "status": "done",
202
+ "result": job_result
203
+ })
204
+ job.done.set()
205
+
206
+ except Exception as e:
207
+ logger.error(f"Validation batch failed: {e}", exc_info=True)
208
+ for job in batch:
209
+ if job.job_id not in validation_job_store:
210
+ validation_job_store[job.job_id] = {"status": "error", "result": None, "error": str(e)}
211
+ else:
212
+ validation_job_store[job.job_id].update({
213
+ "status": "error",
214
+ "error": str(e)
215
+ })
216
+ if not job.done.is_set():
217
+ job.done.set()
218
+ finally:
219
+ set_request_in_flight(False)
220
+
221
+
222
  # ═══════════════════════ Warmup Loop ════════════════════════
223
 
224
 
 
230
  Skipped entirely if a real request is in flight.
231
  """
232
  from src.rag.rag import warmup_embedder
233
+ from src.materials.validator import warmup_validation_models
234
 
235
  loop = asyncio.get_event_loop()
236
 
 
241
  t0 = time.monotonic()
242
  try:
243
  await loop.run_in_executor(None, warmup_embedder)
244
+ await loop.run_in_executor(None, warmup_validation_models)
245
  except Exception as e:
246
  logger.warning(f"Warmup cycle error (non-fatal): {e}")
247
  continue
 
259
  Launch all async worker coroutines. Call once during app startup.
260
 
261
  - 1 embedding worker (batched SentenceTransformer inference)
262
+ - 1 validation worker (batched translation, gibberish, NSFW models validation)
263
  - 1 warmup loop (keeps OpenMP threads alive)
264
  """
265
  global _workers_started
 
269
 
270
  # Use only 1 worker to save RAM on this environment
271
  asyncio.create_task(embedding_worker(), name="embedding_worker_0")
272
+ asyncio.create_task(validation_worker(), name="validation_worker_0")
273
  asyncio.create_task(_warmup_loop(), name="warmup_loop")
274
 
275
+ logger.info("Batch workers started (embedding worker + validation worker + warmup loop)")
rag/schemas.py CHANGED
@@ -17,6 +17,15 @@ class EmbeddingJob:
17
  done: asyncio.Event = field(default_factory=asyncio.Event)
18
 
19
 
 
 
 
 
 
 
 
 
 
20
  # Tutor query and response schemas
21
  class TutorQuery(BaseModel):
22
  query: str
 
17
  done: asyncio.Event = field(default_factory=asyncio.Event)
18
 
19
 
20
+ @dataclass
21
+ class ValidationJob:
22
+ """Batch validation of a topic text."""
23
+ job_id: str
24
+ text: str
25
+ done: asyncio.Event = field(default_factory=asyncio.Event)
26
+
27
+
28
+
29
  # Tutor query and response schemas
30
  class TutorQuery(BaseModel):
31
  query: str