Ali Hashhash commited on
Commit
568a4d9
·
1 Parent(s): 8ff5b20

feat: implement local model inference with Qwen2.5 and mDeBERTa, and add recommendation system service.

Browse files
pyproject.toml CHANGED
@@ -11,7 +11,7 @@ dependencies = [
11
  "email-validator>=2.3.0",
12
  "fastapi==0.109.0",
13
  "google-api-python-client==2.115.0",
14
- "groq>=0.9.0",
15
  "greenlet==3.3.1",
16
  "httpx==0.26.0",
17
  "openai-whisper==20250625",
 
11
  "email-validator>=2.3.0",
12
  "fastapi==0.109.0",
13
  "google-api-python-client==2.115.0",
14
+ "accelerate>=0.27.0",
15
  "greenlet==3.3.1",
16
  "httpx==0.26.0",
17
  "openai-whisper==20250625",
requirements.txt CHANGED
@@ -5,11 +5,18 @@ bgutil-ytdlp-pot-provider==1.3.1
5
  youtube-transcript-api==0.6.2
6
  curl_cffi
7
 
8
- # --- AI, LLMs & Transcription Fallback ---
9
- groq>=0.9.0
10
- openai-whisper==20250625
11
- torch
12
  torchaudio
 
 
 
 
 
 
 
 
13
 
14
  # --- Backend Infrastructure (FastAPI) ---
15
  fastapi==0.109.0
@@ -18,6 +25,7 @@ pydantic==2.12.5
18
  pydantic[email]
19
  email-validator
20
  pydantic-settings==2.1.0
 
21
  python-multipart==0.0.6
22
  python-dotenv==1.0.0
23
  httpx[socks]==0.26.0
@@ -31,28 +39,9 @@ greenlet==3.3.1
31
  passlib[bcrypt]==1.7.4
32
  python-jose[cryptography]==3.3.0
33
  bcrypt==4.1.2
34
- pydantic-core==2.41.5
35
 
36
  # --- Integration & Utilities ---
37
  google-api-python-client==2.115.0
38
  firebase-admin==6.5.0
39
  dnspython
40
-
41
- # --- ML & Recommendations ---
42
- # keybert
43
- # sentence-transformers
44
  edge-tts>=6.1.9
45
- # --- ML & Summeraization ---
46
- transformers>=4.35.0
47
- torch>=2.0.0 --extra-index-url https://download.pytorch.org/whl/cpu
48
- sentencepiece>=0.1.99
49
- protobuf>=3.20.3
50
- pydantic>=2.0
51
- langdetect>=1.0.9
52
- --extra-index-url https://download.pytorch.org/whl/cpu
53
- torch==2.2.2+cpu
54
- transformers==4.38.2
55
- sentencepiece==0.2.0
56
- protobuf==4.25.3
57
- pydantic>=2.0
58
- langdetect>=1.0.9
 
5
  youtube-transcript-api==0.6.2
6
  curl_cffi
7
 
8
+ # --- AI & Local Models (CPU-only, HuggingFace Spaces 16GB) ---
9
+ --extra-index-url https://download.pytorch.org/whl/cpu
10
+ torch==2.2.2+cpu
 
11
  torchaudio
12
+ transformers==4.38.2
13
+ sentencepiece==0.2.0
14
+ accelerate>=0.27.0
15
+ protobuf==4.25.3
16
+ langdetect>=1.0.9
17
+
18
+ # --- Whisper (local transcription fallback) ---
19
+ openai-whisper==20250625
20
 
21
  # --- Backend Infrastructure (FastAPI) ---
22
  fastapi==0.109.0
 
25
  pydantic[email]
26
  email-validator
27
  pydantic-settings==2.1.0
28
+ pydantic-core==2.41.5
29
  python-multipart==0.0.6
30
  python-dotenv==1.0.0
31
  httpx[socks]==0.26.0
 
39
  passlib[bcrypt]==1.7.4
40
  python-jose[cryptography]==3.3.0
41
  bcrypt==4.1.2
 
42
 
43
  # --- Integration & Utilities ---
44
  google-api-python-client==2.115.0
45
  firebase-admin==6.5.0
46
  dnspython
 
 
 
 
47
  edge-tts>=6.1.9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/api/chat_routes.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Chat routes — Document-specific Q&A powered by Groq.
3
  """
4
 
5
  from typing import List, Optional
 
1
  """
2
+ Chat routes — Document-specific Q&A powered by local Qwen model.
3
  """
4
 
5
  from typing import List, Optional
src/categorization/topic_classifier.py CHANGED
@@ -16,6 +16,10 @@ Categories:
16
 
17
  from typing import List, Set
18
 
 
 
 
 
19
 
20
  # ─────────────────────────────────────────────────────────────────────────────
21
  # PREDEFINED CATEGORIES
@@ -257,3 +261,72 @@ def get_primary_category(topics: List[str]) -> str:
257
  Alias for classify_topic().
258
  """
259
  return classify_topic(topics)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  from typing import List, Set
18
 
19
+ from src.utils.logger import setup_logger
20
+
21
+ logger = setup_logger(__name__)
22
+
23
 
24
  # ─────────────────────────────────────────────────────────────────────────────
25
  # PREDEFINED CATEGORIES
 
261
  Alias for classify_topic().
262
  """
263
  return classify_topic(topics)
264
+
265
+
266
+ # ─────────────────────────────────────────────────────────────────────────────
267
+ # ZERO-SHOT CLASSIFICATION (mDeBERTa fallback)
268
+ # ─────────────────────────────────────────────────────────────────────────────
269
+
270
+ def classify_topic_zeroshot(text: str) -> str:
271
+ """Classify free-form text into one of the predefined UI categories
272
+ using the mDeBERTa zero-shot classification pipeline.
273
+
274
+ Args:
275
+ text: Free-form text (transcript excerpt, note body, etc.)
276
+
277
+ Returns:
278
+ The best-matching category string from CATEGORIES.
279
+ """
280
+ if not text or not text.strip():
281
+ return "Education & Science"
282
+
283
+ try:
284
+ from src.utils.model_loader import get_classifier_pipeline
285
+
286
+ classifier = get_classifier_pipeline()
287
+
288
+ # Truncate to ~500 chars for speed on CPU
289
+ result = classifier(
290
+ text[:500],
291
+ candidate_labels=CATEGORIES,
292
+ multi_label=False,
293
+ )
294
+
295
+ best_label = result["labels"][0]
296
+ best_score = result["scores"][0]
297
+ logger.info(
298
+ "🏷️ Zero-shot classification: %s (score=%.3f)", best_label, best_score
299
+ )
300
+ return best_label
301
+
302
+ except Exception as e:
303
+ logger.warning("⚠️ Zero-shot classification failed: %s — falling back", e)
304
+ return "Education & Science"
305
+
306
+
307
+ def classify_topic_hybrid(topics: List[str], text: str = "") -> str:
308
+ """Best-of-both-worlds classifier.
309
+
310
+ 1. First tries fast keyword matching via ``classify_topic(topics)``.
311
+ 2. If the result is the generic fallback ("Education & Science") AND
312
+ ``text`` is provided, runs the mDeBERTa zero-shot classifier on
313
+ the text for a more nuanced result.
314
+
315
+ Args:
316
+ topics: List of topic strings (from the summarization pipeline).
317
+ text: Optional free-form text for zero-shot fallback.
318
+
319
+ Returns:
320
+ A single category string from CATEGORIES.
321
+ """
322
+ keyword_result = classify_topic(topics)
323
+
324
+ # If keyword matching gave a confident answer, use it
325
+ if keyword_result != "Education & Science":
326
+ return keyword_result
327
+
328
+ # If we have text, try zero-shot as a fallback
329
+ if text and text.strip():
330
+ return classify_topic_zeroshot(text)
331
+
332
+ return keyword_result
src/recommendation/README.md CHANGED
@@ -48,7 +48,7 @@ async def test_recommendations():
48
 
49
  ## Libraries Used
50
  - `google-api-python-client` - Search YouTube.
51
- - `groq` - Extract topics via LLM.
52
 
53
  ## Important Notes
54
  - YouTube API has a **daily quota limit**, use caching to reduce requests.
 
48
 
49
  ## Libraries Used
50
  - `google-api-python-client` - Search YouTube.
51
+ - `transformers` - Extract topics via local Qwen model.
52
 
53
  ## Important Notes
54
  - YouTube API has a **daily quota limit**, use caching to reduce requests.
src/recommendation/recommender.py CHANGED
@@ -3,9 +3,8 @@ from collections import Counter
3
  from typing import List, Dict, Optional
4
  from googleapiclient.discovery import build
5
  from src.utils.logger import setup_logger
 
6
  import random
7
- # import anthropic
8
- from groq import Groq
9
 
10
  logger = setup_logger(__name__)
11
 
@@ -17,14 +16,13 @@ class RecommendationService:
17
  Service for suggesting videos based on user's saved notes.
18
  Pipeline:
19
  1. Top 3 most-repeated categories across all user notes
20
- 2. Extract key keywords from the latest note per category (via Claude)
21
  3. Build a YouTube search query and return recommendations
22
  """
23
 
24
  def __init__(self, api_key: Optional[str] = None):
25
  self.api_key = "AIzaSyA3erB-Lxd5SOoBOXaumOCVaEr3TcgYG60"
26
  self.youtube = build("youtube", "v3", developerKey=self.api_key)
27
- self.groq_client = Groq(api_key="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")
28
 
29
  # ──────────────────────────────────────────────
30
  # Step 1: top 3 categories
@@ -72,7 +70,7 @@ class RecommendationService:
72
  }
73
 
74
  # ──────────────────────────────────────────────
75
- # Step 3: extract keywords via grok(llama-3.3-70b-versatile)
76
  # ──────────────────────────────────────────────
77
  async def _extract_keywords_with_claude(
78
  self, notes: List[Dict], category: str
@@ -98,16 +96,15 @@ class RecommendationService:
98
 
99
  try:
100
  loop = asyncio.get_event_loop()
101
- # groq_client = Groq(api_key="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")
102
- response = await loop.run_in_executor(
103
  None,
104
- lambda: self.groq_client.chat.completions.create(
105
- model="llama-3.3-70b-versatile",
106
- messages=[{"role": "user", "content": prompt}],
107
- max_tokens=120,
108
- )
109
  )
110
- raw = response.choices[0].message.content.strip()
111
  import json, re
112
  # strip accidental markdown fences
113
  raw = re.sub(r"```json|```", "", raw).strip()
@@ -116,7 +113,7 @@ class RecommendationService:
116
  logger.info(f" Keywords for '{category}': {keywords}")
117
  return [str(k) for k in keywords[:5]]
118
  except Exception as e:
119
- logger.warning(f" Grok keyword extraction failed for '{category}': {e}")
120
 
121
  return [category] # fallback
122
 
@@ -220,7 +217,7 @@ class RecommendationService:
220
  logger.info(" No valid categories — falling back")
221
  return await self.get_youtube_recommendations("educational tutorials", limit)
222
 
223
- # ── Step 2: keywords via Claude ──────────
224
  latest_notes = self._latest_notes_per_category(notes, top_categories, top_n=2)
225
 
226
  valid_categories = [
 
3
  from typing import List, Dict, Optional
4
  from googleapiclient.discovery import build
5
  from src.utils.logger import setup_logger
6
+ from src.utils.model_loader import generate_text
7
  import random
 
 
8
 
9
  logger = setup_logger(__name__)
10
 
 
16
  Service for suggesting videos based on user's saved notes.
17
  Pipeline:
18
  1. Top 3 most-repeated categories across all user notes
19
+ 2. Extract key keywords from the latest note per category (via local Qwen model)
20
  3. Build a YouTube search query and return recommendations
21
  """
22
 
23
  def __init__(self, api_key: Optional[str] = None):
24
  self.api_key = "AIzaSyA3erB-Lxd5SOoBOXaumOCVaEr3TcgYG60"
25
  self.youtube = build("youtube", "v3", developerKey=self.api_key)
 
26
 
27
  # ──────────────────────────────────────────────
28
  # Step 1: top 3 categories
 
70
  }
71
 
72
  # ──────────────────────────────────────────────
73
+ # Step 3: extract keywords via local Qwen model
74
  # ──────────────────────────────────────────────
75
  async def _extract_keywords_with_claude(
76
  self, notes: List[Dict], category: str
 
96
 
97
  try:
98
  loop = asyncio.get_event_loop()
99
+ # Run local model inference in a thread pool to avoid blocking the event loop
100
+ raw = await loop.run_in_executor(
101
  None,
102
+ lambda: generate_text(
103
+ [{"role": "user", "content": prompt}],
104
+ max_new_tokens=80,
105
+ ),
 
106
  )
107
+
108
  import json, re
109
  # strip accidental markdown fences
110
  raw = re.sub(r"```json|```", "", raw).strip()
 
113
  logger.info(f" Keywords for '{category}': {keywords}")
114
  return [str(k) for k in keywords[:5]]
115
  except Exception as e:
116
+ logger.warning(f" Local keyword extraction failed for '{category}': {e}")
117
 
118
  return [category] # fallback
119
 
 
217
  logger.info(" No valid categories — falling back")
218
  return await self.get_youtube_recommendations("educational tutorials", limit)
219
 
220
+ # ── Step 2: keywords via local Qwen model ──────────
221
  latest_notes = self._latest_notes_per_category(notes, top_categories, top_n=2)
222
 
223
  valid_categories = [
src/summarization/note_generator.py CHANGED
@@ -2,11 +2,10 @@ import os
2
  import re
3
  from typing import Dict, List
4
 
5
- import torch
6
- from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
7
  from pydantic import ValidationError
8
 
9
  from ..utils.logger import setup_logger
 
10
  from .segmenter import TranscriptSegmenter
11
  from .schemas import SummarySchema, SegmentSchema
12
 
@@ -27,7 +26,7 @@ WHITESPACE_RE = re.compile(r"\s+")
27
 
28
 
29
  class NoteGenerator:
30
- """Real AI Summarization Engine using a lightweight multilingual mT5 model.
31
 
32
  Pipeline (map-reduce):
33
  1. MAP -> split transcript into word-bounded chunks (TranscriptSegmenter)
@@ -40,32 +39,18 @@ class NoteGenerator:
40
  """
41
 
42
  def __init__(self, chunk_size_words: int = 350):
43
- self.model_id = os.environ.get("MT5_MODEL_NAME", "csebuetnlp/mT5_multilingual_XLSum")
44
- logger.info(f"🤖 Initializing real local AI Model: {self.model_id}...")
45
-
46
- cache_dir = os.path.join(os.getcwd(), "hf_cache")
47
- os.makedirs(cache_dir, exist_ok=True)
48
-
49
- self.tokenizer = AutoTokenizer.from_pretrained(
50
- self.model_id, use_fast=False, cache_dir=cache_dir
51
- )
52
- self.device = "cuda" if torch.cuda.is_available() else "cpu"
53
- self.model = AutoModelForSeq2SeqLM.from_pretrained(
54
- self.model_id, cache_dir=cache_dir
55
- ).to(self.device)
56
-
57
  self.segmenter = TranscriptSegmenter(max_segment_words=chunk_size_words)
58
  self.chunk_size_words = chunk_size_words
 
59
 
60
- logger.info("✅ Real AI Model loaded successfully and ready!")
61
 
62
  def _run_model_summarization(
63
  self,
64
  text: str,
65
- max_length: int = 84,
66
- num_beams: int = 2,
67
  ) -> str:
68
- """Run the local mT5 model on a single piece of text and return its summary."""
69
  try:
70
  clean_text = WHITESPACE_RE.sub(" ", text.strip())
71
  if not clean_text:
@@ -74,27 +59,22 @@ class NoteGenerator:
74
 
75
  logger.info(f"🔎 Input to model (len={len(clean_text)} chars): {clean_text[:120]!r}...")
76
 
77
- inputs = self.tokenizer(
78
- [clean_text],
79
- truncation=True,
80
- padding="longest",
81
- max_length=512,
82
- return_tensors="pt",
83
- ).to(self.device)
84
-
85
- logger.info(f"🔎 Tokenized input_ids shape: {inputs['input_ids'].shape}")
86
-
87
- with torch.no_grad():
88
- summary_ids = self.model.generate(
89
- inputs["input_ids"],
90
- num_beams=num_beams,
91
- max_length=max_length,
92
- no_repeat_ngram_size=2,
93
- early_stopping=True,
94
- )
95
 
96
- result = self.tokenizer.decode(summary_ids[0], skip_special_tokens=True)
97
- logger.info(f"🔎 Raw model output (len={len(result)} chars): {result!r}")
 
98
 
99
  if not result or len(result.strip()) < 5:
100
  logger.warning(
@@ -174,7 +154,7 @@ class NoteGenerator:
174
  def _reduce_summary(self, segments_list: List[Dict], video_title: str) -> str:
175
  """REDUCE step: summarize the combined chunk-summaries into one overall summary."""
176
  combined_text = " ".join(seg["summary"] for seg in segments_list)
177
- overall = self._run_model_summarization(combined_text, max_length=130, num_beams=4)
178
 
179
  if not overall or len(overall.strip()) < 5:
180
  overall = f"استعراض شامل ومناقشة تفصيلية لموضوع: {video_title}."
@@ -183,7 +163,7 @@ class NoteGenerator:
183
 
184
  def generateSummary(self, transcript_text: str, video_title: str) -> Dict:
185
  """Generates a structured AI summary, validated against SummarySchema."""
186
- logger.info("📝 Real AI summary generation triggered (map-reduce pipeline).")
187
  logger.info(
188
  f"🔎 Received video_title={video_title!r}, "
189
  f"transcript_text length={len(transcript_text) if transcript_text else 0} chars, "
@@ -211,8 +191,8 @@ class NoteGenerator:
211
  "summary": overall_summary,
212
  "segments": segments_list,
213
  "suggested_category": "General",
214
- "conclusion": f"تم التلخيص بنجاح باستخدام موديل mT5 المطور لـ {video_title}.",
215
- "topics": ["تلخيص تلقائي", "ذكاء اصطناعي محلي", "mT5-XLSum"],
216
  }
217
 
218
  # 3. VALIDATE — enforce the schema contract before returning
@@ -238,8 +218,8 @@ class NoteGenerator:
238
  )
239
  for i in range(3)
240
  ],
241
- conclusion=f"تم التلخيص بنجاح باستخدام موديل mT5 المطور لـ {video_title}.",
242
- topics=["تلخيص تلقائي", "ذكاء اصطناعي محلي", "mT5-XLSum"],
243
  )
244
  return fallback.model_dump()
245
 
@@ -301,5 +281,42 @@ class NoteGenerator:
301
  def chat_with_note(
302
  self, note_content: str, question: str, history: list[dict] | None = None
303
  ) -> str:
304
- """Instant stable QA response."""
305
- return "تم تفعيل الموديل المحلي بنجاح! نظام الأسئلة والأجوبة سيتم ربطه في التحديث القادم."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import re
3
  from typing import Dict, List
4
 
 
 
5
  from pydantic import ValidationError
6
 
7
  from ..utils.logger import setup_logger
8
+ from ..utils.model_loader import generate_text
9
  from .segmenter import TranscriptSegmenter
10
  from .schemas import SummarySchema, SegmentSchema
11
 
 
26
 
27
 
28
  class NoteGenerator:
29
+ """AI Summarization Engine using Qwen2.5-1.5B-Instruct (local, CPU).
30
 
31
  Pipeline (map-reduce):
32
  1. MAP -> split transcript into word-bounded chunks (TranscriptSegmenter)
 
39
  """
40
 
41
  def __init__(self, chunk_size_words: int = 350):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  self.segmenter = TranscriptSegmenter(max_segment_words=chunk_size_words)
43
  self.chunk_size_words = chunk_size_words
44
+ logger.info("📝 NoteGenerator ready (Qwen2.5-1.5B-Instruct, CPU).")
45
 
46
+ # ── Core summarization via local Qwen model ──────────────────────────
47
 
48
  def _run_model_summarization(
49
  self,
50
  text: str,
51
+ max_new_tokens: int = 200,
 
52
  ) -> str:
53
+ """Run the local Qwen model on a single piece of text and return its summary."""
54
  try:
55
  clean_text = WHITESPACE_RE.sub(" ", text.strip())
56
  if not clean_text:
 
59
 
60
  logger.info(f"🔎 Input to model (len={len(clean_text)} chars): {clean_text[:120]!r}...")
61
 
62
+ messages = [
63
+ {
64
+ "role": "system",
65
+ "content": (
66
+ "You are a professional summarizer. "
67
+ "Summarize the following text concisely in the SAME language as the input. "
68
+ "Focus on the main ideas and key points. "
69
+ "Output ONLY the summary, nothing else."
70
+ ),
71
+ },
72
+ {"role": "user", "content": clean_text[:3000]},
73
+ ]
 
 
 
 
 
 
74
 
75
+ result = generate_text(messages, max_new_tokens=max_new_tokens)
76
+
77
+ logger.info(f"🔎 Model output (len={len(result)} chars): {result[:200]!r}")
78
 
79
  if not result or len(result.strip()) < 5:
80
  logger.warning(
 
154
  def _reduce_summary(self, segments_list: List[Dict], video_title: str) -> str:
155
  """REDUCE step: summarize the combined chunk-summaries into one overall summary."""
156
  combined_text = " ".join(seg["summary"] for seg in segments_list)
157
+ overall = self._run_model_summarization(combined_text, max_new_tokens=250)
158
 
159
  if not overall or len(overall.strip()) < 5:
160
  overall = f"استعراض شامل ومناقشة تفصيلية لموضوع: {video_title}."
 
163
 
164
  def generateSummary(self, transcript_text: str, video_title: str) -> Dict:
165
  """Generates a structured AI summary, validated against SummarySchema."""
166
+ logger.info("📝 AI summary generation triggered (map-reduce pipeline, Qwen local).")
167
  logger.info(
168
  f"🔎 Received video_title={video_title!r}, "
169
  f"transcript_text length={len(transcript_text) if transcript_text else 0} chars, "
 
191
  "summary": overall_summary,
192
  "segments": segments_list,
193
  "suggested_category": "General",
194
+ "conclusion": f"تم التلخيص بنجاح باستخدام نموذج Qwen المحلي لـ {video_title}.",
195
+ "topics": ["تلخيص تلقائي", "ذكاء اصطناعي محلي", "Qwen2.5"],
196
  }
197
 
198
  # 3. VALIDATE — enforce the schema contract before returning
 
218
  )
219
  for i in range(3)
220
  ],
221
+ conclusion=f"تم التلخيص بنجاح باستخدام نموذج Qwen المحلي لـ {video_title}.",
222
+ topics=["تلخيص تلقائي", "ذكاء اصطناعي محلي", "Qwen2.5"],
223
  )
224
  return fallback.model_dump()
225
 
 
281
  def chat_with_note(
282
  self, note_content: str, question: str, history: list[dict] | None = None
283
  ) -> str:
284
+ """Answer a question about a note using the local Qwen model.
285
+
286
+ The model is instructed to ground its answers solely in the provided
287
+ note content to avoid hallucination.
288
+ """
289
+ try:
290
+ messages = [
291
+ {
292
+ "role": "system",
293
+ "content": (
294
+ "You are a helpful study assistant. "
295
+ "Answer the user's question based ONLY on the note content provided below. "
296
+ "If the answer is not in the note, say so honestly. "
297
+ "Reply in the same language the user uses.\n\n"
298
+ f"--- NOTE CONTENT ---\n{note_content[:4000]}\n--- END NOTE ---"
299
+ ),
300
+ },
301
+ ]
302
+
303
+ # Include conversation history if available
304
+ if history:
305
+ for msg in history[-6:]: # Keep last 6 turns to fit context
306
+ role = msg.get("role", "user")
307
+ content = msg.get("content", "")
308
+ if role in ("user", "assistant") and content:
309
+ messages.append({"role": role, "content": content})
310
+
311
+ messages.append({"role": "user", "content": question})
312
+
313
+ answer = generate_text(messages, max_new_tokens=300)
314
+
315
+ if not answer or len(answer.strip()) < 3:
316
+ return "عذرًا، لم أتمكن من توليد إجابة. يرجى إعادة صياغة السؤال."
317
+
318
+ return answer
319
+
320
+ except Exception as e:
321
+ logger.error(f"❌ Chat error: {e}", exc_info=True)
322
+ return "حدث خطأ أثناء معالجة سؤالك. يرجى المحاولة مرة أخرى."
src/transcription/downloader.py CHANGED
@@ -20,7 +20,7 @@ class NoTranscriptError(RuntimeError):
20
  # ─────────────────────────────────────────────────────────────────────────────
21
 
22
  class YouTubeDownloader:
23
- """Extracts YouTube transcripts via Supadata or Deep Scan (Groq Whisper)."""
24
 
25
  def __init__(self):
26
  self._supadata_key = os.environ.get("SUPADATA_API_KEY", "").strip()
 
20
  # ─────────────────────────────────────────────────────────────────────────────
21
 
22
  class YouTubeDownloader:
23
+ """Extracts YouTube transcripts via Supadata or Deep Scan (local Whisper)."""
24
 
25
  def __init__(self):
26
  self._supadata_key = os.environ.get("SUPADATA_API_KEY", "").strip()
src/utils/model_loader.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lazy-loading singleton for local AI models.
3
+
4
+ Manages two models for CPU inference on HuggingFace Spaces (16 GB RAM):
5
+
6
+ 1. **Qwen2.5-1.5B-Instruct** – causal LM for summarization, chat Q&A,
7
+ and keyword extraction (replaces all former Groq calls).
8
+ 2. **mDeBERTa-v3-base-xnli-multiset** – zero-shot classifier for topic
9
+ categorization.
10
+
11
+ Both models are loaded *lazily* on first use and cached for the lifetime
12
+ of the process, keeping cold-start memory low.
13
+ """
14
+
15
+ import os
16
+ import threading
17
+ from typing import Tuple
18
+
19
+ import torch
20
+ from transformers import (
21
+ AutoModelForCausalLM,
22
+ AutoTokenizer,
23
+ pipeline as hf_pipeline,
24
+ )
25
+
26
+ from src.utils.logger import setup_logger
27
+
28
+ logger = setup_logger(__name__)
29
+
30
+ # ── Configuration ────────────────────────────────────────────────────────────
31
+ QWEN_MODEL_ID = os.environ.get(
32
+ "QWEN_MODEL_ID", "Qwen/Qwen2.5-1.5B-Instruct"
33
+ )
34
+ CLASSIFIER_MODEL_ID = os.environ.get(
35
+ "CLASSIFIER_MODEL_ID", "MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"
36
+ )
37
+ HF_CACHE_DIR = os.path.join(os.getcwd(), "hf_cache")
38
+ os.makedirs(HF_CACHE_DIR, exist_ok=True)
39
+
40
+ # ── Internal state (module-level singletons) ─────────────────────────────────
41
+ _qwen_lock = threading.Lock()
42
+ _clf_lock = threading.Lock()
43
+
44
+ _qwen_model = None
45
+ _qwen_tokenizer = None
46
+ _classifier_pipe = None
47
+
48
+
49
+ # ── Public API ───────────────────────────────────────────────────────────────
50
+
51
+ def get_qwen_model() -> Tuple:
52
+ """Return ``(model, tokenizer)`` for Qwen2.5-1.5B-Instruct.
53
+
54
+ Loads on first call; subsequent calls return the cached objects.
55
+ """
56
+ global _qwen_model, _qwen_tokenizer
57
+
58
+ if _qwen_model is not None:
59
+ return _qwen_model, _qwen_tokenizer
60
+
61
+ with _qwen_lock:
62
+ # Double-check after acquiring the lock
63
+ if _qwen_model is not None:
64
+ return _qwen_model, _qwen_tokenizer
65
+
66
+ logger.info("🤖 Loading Qwen model: %s (CPU, float32) …", QWEN_MODEL_ID)
67
+
68
+ _qwen_tokenizer = AutoTokenizer.from_pretrained(
69
+ QWEN_MODEL_ID,
70
+ cache_dir=HF_CACHE_DIR,
71
+ trust_remote_code=True,
72
+ )
73
+ _qwen_model = AutoModelForCausalLM.from_pretrained(
74
+ QWEN_MODEL_ID,
75
+ cache_dir=HF_CACHE_DIR,
76
+ torch_dtype=torch.float32,
77
+ device_map="cpu",
78
+ trust_remote_code=True,
79
+ )
80
+ _qwen_model.eval()
81
+
82
+ logger.info("✅ Qwen model loaded successfully.")
83
+ return _qwen_model, _qwen_tokenizer
84
+
85
+
86
+ def get_classifier_pipeline():
87
+ """Return a zero-shot-classification ``Pipeline`` backed by mDeBERTa.
88
+
89
+ Loads on first call; subsequent calls return the cached pipeline.
90
+ """
91
+ global _classifier_pipe
92
+
93
+ if _classifier_pipe is not None:
94
+ return _classifier_pipe
95
+
96
+ with _clf_lock:
97
+ if _classifier_pipe is not None:
98
+ return _classifier_pipe
99
+
100
+ logger.info(
101
+ "🤖 Loading zero-shot classifier: %s (CPU) …", CLASSIFIER_MODEL_ID
102
+ )
103
+
104
+ _classifier_pipe = hf_pipeline(
105
+ "zero-shot-classification",
106
+ model=CLASSIFIER_MODEL_ID,
107
+ device=-1, # CPU
108
+ cache_dir=HF_CACHE_DIR,
109
+ )
110
+
111
+ logger.info("✅ Zero-shot classifier loaded successfully.")
112
+ return _classifier_pipe
113
+
114
+
115
+ def generate_text(
116
+ prompt_messages: list[dict],
117
+ *,
118
+ max_new_tokens: int = 200,
119
+ temperature: float = 1.0,
120
+ do_sample: bool = False,
121
+ ) -> str:
122
+ """High-level helper: run Qwen chat completion and return the reply text.
123
+
124
+ Parameters
125
+ ----------
126
+ prompt_messages:
127
+ List of ``{"role": ..., "content": ...}`` dicts compatible with
128
+ ``tokenizer.apply_chat_template``.
129
+ max_new_tokens:
130
+ Cap on generated tokens (keep low for CPU speed).
131
+ temperature / do_sample:
132
+ Sampling config. Defaults to greedy (deterministic).
133
+ """
134
+ model, tokenizer = get_qwen_model()
135
+
136
+ input_text = tokenizer.apply_chat_template(
137
+ prompt_messages,
138
+ tokenize=False,
139
+ add_generation_prompt=True,
140
+ )
141
+
142
+ inputs = tokenizer(
143
+ input_text,
144
+ return_tensors="pt",
145
+ truncation=True,
146
+ max_length=2048,
147
+ ).to("cpu")
148
+
149
+ prompt_len = inputs["input_ids"].shape[1]
150
+
151
+ with torch.no_grad():
152
+ output_ids = model.generate(
153
+ **inputs,
154
+ max_new_tokens=max_new_tokens,
155
+ do_sample=do_sample,
156
+ temperature=temperature if do_sample else 1.0,
157
+ pad_token_id=tokenizer.eos_token_id,
158
+ )
159
+
160
+ # Decode only the newly generated tokens
161
+ new_tokens = output_ids[0][prompt_len:]
162
+ return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()