Riy777 commited on
Commit
03c9acf
·
verified ·
1 Parent(s): 0771ac3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +59 -146
app.py CHANGED
@@ -14,18 +14,17 @@ import fitz # PyMuPDF
14
  from PIL import Image
15
  import io
16
  import requests
17
- from fastapi import FastAPI, Request, HTTPException
18
- from fastapi.responses import HTMLResponse, JSONResponse # استيراد JSONResponse
19
  import uvicorn
20
  import random
21
- import docx # لإضافة دعم .docx
22
 
23
  # ========== إضافة المكتبات الصحيحة لحل مشكلة DNS + Lifespan + Typing ==========
24
  import httpx
25
  import dns.asyncresolver
26
- from httpx import AsyncClient, AsyncHTTPTransport, Request, Response
27
- import contextlib # <-- إضافة المكتبة الصحيحة
28
- from typing import Dict, Any # <-- إضافة Dict و Any
29
  # ==========================================================
30
 
31
  # ========== تكوين السجلات ==========
@@ -61,23 +60,20 @@ class CustomDNSTransport(AsyncHTTPTransport):
61
  self.resolver.nameservers = ['1.1.1.1', '8.8.8.8']
62
  logger.info("🔧 CustomDNSTransport initialized with 1.1.1.1 and 8.8.8.8")
63
 
64
- async def handle_async_request(self, request: Request) -> Response:
65
  try:
66
  host = request.url.host
67
 
68
  # التحقق إذا كان Host هو أصلاً IP
69
  is_ip = True
70
  try:
71
- # تحقق مبسط لـ IPv4
72
  parts = host.split('.')
73
  if len(parts) != 4 or not all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
74
  is_ip = False
75
- # يمكن إضافة تحقق لـ IPv6 إذا لزم الأمر
76
  except Exception:
77
  is_ip = False
78
 
79
  if is_ip:
80
- # logger.info(f"Host {host} is already an IP. Skipping resolution.") # يمكن إلغاء التعليق عند الحاجة للتتبع
81
  pass
82
  else:
83
  logger.info(f"🔧 Resolving host: {host}")
@@ -85,9 +81,7 @@ class CustomDNSTransport(AsyncHTTPTransport):
85
  ip = result[0].address
86
  logger.info(f"✅ Resolved {host} to {ip}")
87
 
88
- # تعيين SNI (مهم جداً لـ SSL/HTTPS)
89
  request.extensions["sni_hostname"] = host
90
- # تحديث الـ URL لاستخدام الـ IP
91
  request.url = request.url.copy_with(host=ip)
92
 
93
  except dns.resolver.NoAnswer:
@@ -96,10 +90,8 @@ class CustomDNSTransport(AsyncHTTPTransport):
96
  logger.error(f"❌ DNS NXDOMAIN for {host}. Trying request with original host...")
97
  except Exception as e:
98
  logger.error(f"❌ DNS resolution failed for {host}: {e}. Trying request with original host...")
99
- # إذا فشل الحل لأي سبب، اسمح للطلب بالاستمرار مع اسم المضيف الأصلي
100
  pass
101
 
102
- # استدعاء المعالج الأصلي (الأب) لإكمال الطلب
103
  return await super().handle_async_request(request)
104
  # ==========================================================
105
 
@@ -124,7 +116,7 @@ class MedicalLabBot:
124
  self.file_cache = {}
125
  self.application = None
126
  self.is_initialized = False
127
- self.initialization_status = "pending" # تتبع حالة التهيئة
128
  self.load_all_materials()
129
 
130
  async def initialize_application(self):
@@ -135,16 +127,13 @@ class MedicalLabBot:
135
 
136
  logger.info("🔄 جاري تهيئة تطبيق التليجرام...")
137
 
138
- # ========== الحل الجذري (DNS) ==========
139
- logger.info("🔧 إعداد عميل HTTP مخصص مع CustomDNSTransport...")
140
  custom_transport = CustomDNSTransport()
141
  custom_client = httpx.AsyncClient(transport=custom_transport)
142
- # ============================================
143
 
144
  self.application = (
145
  Application.builder()
146
  .token(TELEGRAM_BOT_TOKEN)
147
- .http_client(custom_client) # <-- تمرير العميل المخصص هنا
148
  .build()
149
  )
150
  await self.setup_handlers()
@@ -155,11 +144,9 @@ class MedicalLabBot:
155
  for attempt in range(max_retries):
156
  try:
157
  logger.info(f"🚀 محاولة تهيئة الاتصال بـ Telegram (محاولة {attempt + 1}/{max_retries})...")
158
- # 1. الاتصال بـ Telegram (getMe)
159
  await self.application.initialize()
160
  logger.info("✅ تم تهيئة الاتصال بـ Telegram بنجاح.")
161
 
162
- # 2. ضبط الـ Webhook (فقط بعد نجاح الاتصال)
163
  if SPACE_URL:
164
  webhook_url = f"{SPACE_URL.rstrip('/')}/telegram"
165
  logger.info(f"���️ جاري إعداد الويب هوك على: {webhook_url}")
@@ -172,11 +159,10 @@ class MedicalLabBot:
172
  else:
173
  logger.warning("⚠️ SPACE_URL not set. Webhook cannot be set.")
174
 
175
- # 3. ضبط الحالة على "ناجح"
176
  self.is_initialized = True
177
  self.initialization_status = "success"
178
  logger.info("✅✅✅ التطبيق جاهز لاستقبال الطلبات.")
179
- return True # نجح، اخرج من الدالة
180
 
181
  except Exception as e:
182
  logger.warning(f"⚠️ فشلت محاولة التهيئة {attempt + 1}: {e}")
@@ -184,22 +170,20 @@ class MedicalLabBot:
184
  logger.info(f"⏳ الانتظار {retry_delay} ثواني قبل إعادة المحاولة...")
185
  await asyncio.sleep(retry_delay)
186
  else:
187
- logger.error(f"❌ فشل تهيئة التطبيق نهائياً بعد {max_retries} محاولات (حتى مع DNS المخصص).")
188
 
189
- # فشل بعد كل المحاولات
190
  self.is_initialized = False
191
  self.initialization_status = "failed"
192
  return False
193
 
194
  except Exception as e:
195
- logger.error(f"❌ خطأ فادح في تهيئة التطبيق: {e}", exc_info=True) # إضافة exc_info=True
196
  self.is_initialized = False
197
  self.initialization_status = "failed"
198
  return False
199
 
200
  async def setup_handlers(self):
201
  """إعداد معالجات التليجرام"""
202
- # إعداد Conversation Handler مع إعدادات محسنة
203
  conv_handler = ConversationHandler(
204
  entry_points=[CommandHandler('start', self.start)],
205
  states={
@@ -208,7 +192,6 @@ class MedicalLabBot:
208
  ],
209
  SELECTING_ACTION: [
210
  CallbackQueryHandler(self.handle_action_selection, pattern='^(explain_lecture|browse_files|generate_questions|summarize_content|explain_concept|main_menu)$'),
211
- # إضافة معالج العودة هنا أيضاً
212
  CallbackQueryHandler(self.handle_back_actions, pattern='^back_to_actions$')
213
  ],
214
  WAITING_FOR_QUESTION: [
@@ -225,10 +208,7 @@ class MedicalLabBot:
225
  per_message=False
226
  )
227
 
228
- # إضافة الـ handlers
229
  self.application.add_handler(conv_handler)
230
-
231
- # إضافة handlers منفصلة للاستعلامات العامة
232
  self.application.add_handler(CallbackQueryHandler(self.handle_more_questions, pattern='^more_questions$'))
233
  self.application.add_handler(CallbackQueryHandler(self.handle_change_subject, pattern='^change_subject$'))
234
 
@@ -261,7 +241,6 @@ class MedicalLabBot:
261
  materials[subject]['file_details'][file_name] = file_info
262
 
263
  else:
264
- # ملفات في الجذر الرئيسي للمستودع (إن وجدت)
265
  if 'general' not in materials:
266
  materials['general'] = {
267
  'files': [],
@@ -275,19 +254,15 @@ class MedicalLabBot:
275
  logger.error(f"خطأ في معالجة الملف {file_path}: {e}")
276
  continue
277
 
278
- # فرز الملفات داخل كل مادة (اختياري، للترتيب)
279
  for subject in materials:
280
  materials[subject]['files'].sort(key=lambda x: (x['lecture_number'] if x['lecture_number'] is not None else float('inf'), x['name']))
281
 
282
-
283
  self.available_materials = materials
284
  logger.info(f"✅ تم تحميل {len(materials)} مادة بنجاح")
285
 
286
  except Exception as e:
287
  logger.error(f"❌ خطأ في تحميل المواد: {e}")
288
- self.available_materials = {'Biochemistry': {'files': [], 'file_details': {}}} # مادة افتراضية عند الفشل
289
-
290
- # ========== (الدوال التي تم إكمالها) ==========
291
 
292
  def get_user_memory(self, user_id):
293
  """الحصول على ذاكرة المستخدم أو إنشاؤها"""
@@ -323,20 +298,15 @@ class MedicalLabBot:
323
  lecture_num = None
324
  file_type = 'unknown'
325
 
326
- # استخراج رقم المحاضرة (محاولة أكثر مرونة)
327
- # يبحث عن "lec" أو "lecture" أو "محاضرة" متبوعة برقم
328
  match = re.search(r'(?:lecture|lec|محاضرة)\s*(\d+)', name_lower)
329
  if not match:
330
- # إذا لم يجد الصيغة السابقة، يبحث عن رقم لوحده في بداية أو نهاية الاسم
331
  match = re.search(r'^(\d+)\s*-|[\s_-](\d+)$', name_lower)
332
  if match:
333
- # يأخذ الرقم الثاني إذا وجد (المجموعة الثانية)، وإلا الأول
334
  lecture_num_str = match.group(2) or match.group(1)
335
  lecture_num = int(lecture_num_str) if lecture_num_str else None
336
  else:
337
  lecture_num = int(match.group(1))
338
 
339
- # تحديد نوع الملف
340
  if 'lab' in name_lower or 'عملي' in name_lower:
341
  file_type = 'lab'
342
  elif 'exam' in name_lower or 'امتحان' in name_lower or 'اسئلة' in name_lower:
@@ -345,7 +315,6 @@ class MedicalLabBot:
345
  file_type = 'summary'
346
  elif 'lecture' in name_lower or 'محاضرة' in name_lower:
347
  file_type = 'lecture'
348
- # إذا لم يكن أي مما سبق وكان هناك رقم محاضرة، افترضه محاضرة
349
  elif lecture_num is not None:
350
  file_type = 'lecture'
351
 
@@ -359,10 +328,9 @@ class MedicalLabBot:
359
  async def _call_nvidia_api(self, messages, max_tokens=1500):
360
  """دالة مساعدة لاستدعاء NVIDIA API"""
361
  try:
362
- # استخدام asyncio.to_thread لتشغيل الكود المتزامن في thread
363
  completion = await asyncio.to_thread(
364
  nvidia_client.chat.completions.create,
365
- model="meta/llama3-70b-instruct", # استخدام موديل قوي
366
  messages=messages,
367
  temperature=0.5,
368
  top_p=1,
@@ -378,19 +346,16 @@ class MedicalLabBot:
378
  files = self.available_materials[subject]['files']
379
  query_lower = query.lower().strip()
380
 
381
- # 1. محاولة البحث بالرقم التسلسلي في القائمة (مثل "1", "2")
382
  try:
383
- # استخراج الرقم فقط
384
- match_num = re.findall(r'^\d+$', query_lower) # يبحث عن رقم فقط في الاستعلام
385
  if match_num:
386
  index = int(match_num[0])
387
  if 1 <= index <= len(files):
388
  logger.info(f"Found file by index {index}")
389
  return files[index - 1]
390
  except (IndexError, ValueError):
391
- pass # ليس استعلام رقمي
392
 
393
- # 2. محاولة البحث برقم المحاضرة الدقيق (مثل "محاضرة 3", "lec 5")
394
  match_lec_num = re.search(r'(?:lecture|lec|محاضرة)\s*(\d+)', query_lower)
395
  if match_lec_num:
396
  num = int(match_lec_num.group(1))
@@ -399,21 +364,18 @@ class MedicalLabBot:
399
  logger.info(f"Found file by exact lecture number {num}")
400
  return file_info
401
 
402
- # 3. محاولة البحث بجزء من الاسم
403
  best_match = None
404
  highest_score = 0
405
  for file_info in files:
406
  name_lower = file_info['name'].lower()
407
- # حساب درجة التشابه (بسيط) - عدد الكلمات المشتركة
408
  query_words = set(query_lower.split())
409
- name_words = set(re.findall(r'\w+', name_lower)) # استخراج الكلمات من اسم الملف
410
  common_words = query_words.intersection(name_words)
411
  score = len(common_words)
412
 
413
  if score > highest_score:
414
  highest_score = score
415
  best_match = file_info
416
- # إذا تطابق الرقم المستخرج من الاستعلام مع رقم المحاضرة، نعتبره تطابق جيد
417
  elif highest_score > 0 and file_info['lecture_number'] is not None:
418
  num_in_query = re.findall(r'\d+', query_lower)
419
  if num_in_query and file_info['lecture_number'] == int(num_in_query[0]):
@@ -425,17 +387,15 @@ class MedicalLabBot:
425
  return best_match
426
 
427
  logger.warning(f"Could not find file matching query: '{query}' in subject: {subject}")
428
- return None # لم يتم العثور
429
 
430
  async def download_and_extract_content(self, file_path, subject):
431
  """تحميل الملف من HF واستخراج النص منه"""
432
  if file_path in self.file_cache:
433
- # logger.info(f"💾 Using cached content for {file_path}") # إلغاء التعليق للتتبع
434
  return self.file_cache[file_path]
435
 
436
  logger.info(f"⏳ Downloading {file_path} from Hugging Face...")
437
  try:
438
- # استخدام asyncio.to_thread لتشغيل الدالة المتزامنة في thread منفصل
439
  local_path = await asyncio.to_thread(
440
  hf_hub_download,
441
  repo_id=REPO_ID,
@@ -447,18 +407,16 @@ class MedicalLabBot:
447
  logger.info(f"📄 Extracting content from {local_path}")
448
 
449
  if local_path.lower().endswith('.pdf'):
450
- # استخدام PyMuPDF (fitz) لاستخراج أدق
451
  with fitz.open(local_path) as doc:
452
  for page_num, page in enumerate(doc):
453
- text_content += page.get_text("text", sort=True) # محاولة فرز النص
454
- # text_content += f"\n\n--- Page {page_num + 1} ---\n\n" # إضافة فاصل صفحات (اختياري)
455
 
456
  elif local_path.lower().endswith('.txt'):
457
- with open(local_path, 'r', encoding='utf-8', errors='ignore') as f: # تجاهل أخطاء الترميز
458
  text_content = f.read()
459
 
460
  elif local_path.lower().endswith('.docx'):
461
- doc_obj = await asyncio.to_thread(docx.Document, local_path) # تشغيل في thread
462
  full_text = []
463
  for para in doc_obj.paragraphs:
464
  full_text.append(para.text)
@@ -468,24 +426,20 @@ class MedicalLabBot:
468
  logger.warning(f"Unsupported file type: {local_path}")
469
  return f"Error: Unsupported file type ({os.path.basename(file_path)})."
470
 
471
- # تنظيف أساسي ومتقدم
472
- text_content = re.sub(r'\s+\n', '\n', text_content) # إزالة المسافات الزائدة قبل سطر جديد
473
- text_content = re.sub(r'\n{3,}', '\n\n', text_content) # تقليل الأسطر الفارغة المتتالية
474
- text_content = re.sub(r' +', ' ', text_content).strip() # تقليل المسافات المتتالية
475
 
476
  if len(text_content) < 50:
477
  logger.warning(f"File {file_path} content is very short or empty after extraction.")
478
 
479
  self.file_cache[file_path] = text_content
480
- # logger.debug(f"Content for {file_path}: {text_content[:500]}...") # إلغاء التعليق للتتبع
481
  return text_content
482
 
483
  except Exception as e:
484
  logger.error(f"❌ Error downloading/extracting {file_path}: {e}", exc_info=True)
485
  return f"Error: Could not retrieve or process file {os.path.basename(file_path)}."
486
 
487
- # ========== (الدوال الرئيسية) ==========
488
-
489
  async def explain_lecture(self, user_query, subject, user_id):
490
  """شرح محاضرة بناءً على استعلام المستخدم"""
491
  file_info = self._find_file_by_query(user_query, subject)
@@ -508,8 +462,7 @@ class MedicalLabBot:
508
  memory = self.get_user_memory(user_id)
509
  memory['history'].append({"role": "user", "content": f"اشرح لي النقاط الأساسية في هذه المحاضرة: {file_name}"})
510
 
511
- # قص المحتوى ليلائم نافذة السياق (مع مراعاة التوكنات التقريبية)
512
- max_content_chars = 7000 # تقليل لترك مساحة للبرومبت والرد
513
  if len(content) > max_content_chars:
514
  content_snippet = content[:max_content_chars] + "\n\n[... المحتوى مقطوع ...]"
515
  logger.warning(f"Content for {file_name} truncated to {max_content_chars} chars.")
@@ -536,12 +489,11 @@ class MedicalLabBot:
536
 
537
  messages = [
538
  {"role": "system", "content": system_prompt},
539
- # إضافة آخر تفاعلين للسياق إذا كانت ذات صلة بالمفهوم
540
- *memory['history'][-3:-1], # يأخذ العنصرين قبل الأخير (آخر سؤال وجواب)
541
  {"role": "user", "content": f"اشرح لي المفهوم التالي: {user_query}"}
542
  ]
543
 
544
- response = await self._call_nvidia_api(messages, 1000) # تقليل التوكنات للردود المركزة
545
  memory['history'].append({"role": "assistant", "content": response})
546
  return f"🧪 **شرح مفهوم: {user_query}**\n\n{response}"
547
 
@@ -553,7 +505,6 @@ class MedicalLabBot:
553
  system_prompt = f"أنت مساعد ذكي متخصص في المختبرات الطبية. المادة الدراسية الحالية التي يركز عليها الطالب هي '{subject}'. أجب على سؤال الطالب ({user_message}) إجابة واضحة ومباشرة. استخدم سياق المحادثة السابق إذا كان ضرورياً لفهم السؤال. إذا كان السؤال خارج نطاق المادة أو التخصص، اعتذر بلطف."
554
 
555
  messages = [{"role": "system", "content": system_prompt}]
556
- # إضافة آخر 4 تفاعلات للسياق (سؤالين وجوابين)
557
  messages.extend(history[-4:])
558
  messages.append({"role": "user", "content": user_message})
559
 
@@ -568,12 +519,11 @@ class MedicalLabBot:
568
  return "❌ لا توجد ملفات في هذه المادة لتوليد أسئلة منها."
569
 
570
  files = self.available_materials[subject]['files']
571
- # تفضيل ملفات المحاضرات أو الملخصات إن وجدت
572
  preferred_files = [f for f in files if f['type'] in ('lecture', 'summary')]
573
  if preferred_files:
574
  file_info = random.choice(preferred_files)
575
  else:
576
- file_info = random.choice(files) # اختر أي ملف إذا لم توجد المفضلة
577
 
578
  file_path = file_info['path']
579
  file_name = file_info['name']
@@ -583,7 +533,6 @@ class MedicalLabBot:
583
 
584
  if content.startswith("Error:") or not content.strip():
585
  logger.error(f"Failed to get content for question generation from {file_name}. Content: {content[:100]}")
586
- # محاولة اختيار ملف آخر مرة واحدة
587
  if len(files) > 1:
588
  logger.info("Retrying with another file for question generation...")
589
  remaining_files = [f for f in files if f['path'] != file_path]
@@ -620,12 +569,9 @@ class MedicalLabBot:
620
 
621
  files = self.available_materials[subject]['files']
622
 
623
- # إعطاء أولوية للملفات المسماة "summary" أو "ملخص"
624
  summary_file = next((f for f in files if f['type'] == 'summary'), None)
625
- # إذا لم يوجد، اختر أول ملف محاضرة
626
  if not summary_file:
627
  summary_file = next((f for f in files if f['type'] == 'lecture'), None)
628
- # إذا لم يوجد محاضرة أيضاً، اختر أول ملف
629
  if not summary_file:
630
  summary_file = files[0]
631
 
@@ -652,8 +598,6 @@ class MedicalLabBot:
652
  response = await self._call_nvidia_api(messages)
653
  return f"📋 **ملخص ملف: {file_name}**\n\n{response}"
654
 
655
- # ========== (باقي دوال المعالجة الأصلية) ==========
656
-
657
  async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
658
  """بدء المحادثة وعرض القائمة الرئيسية"""
659
  user_id = update.effective_user.id
@@ -670,7 +614,7 @@ class MedicalLabBot:
670
  if not self.available_materials:
671
  welcome_text += "\n\n⚠️ عذراً، لم أتمكن من تحميل أي مواد دراسية حالياً. حاول تحديث القائمة بالضغط على (🔄)."
672
  else:
673
- for subject in sorted(self.available_materials.keys()): # فرز المواد أبجدياً
674
  file_count = len(self.available_materials[subject]['files'])
675
  welcome_text += f"\n• {subject} ({file_count} ملف)"
676
 
@@ -679,13 +623,11 @@ class MedicalLabBot:
679
  keyboard = self.create_subjects_keyboard()
680
  reply_markup = InlineKeyboardMarkup(keyboard)
681
 
682
- # إذا كانت استجابة لـ callback query (مثل العودة للقائمة), عدّل الرسالة
683
  if update.callback_query:
684
  try:
685
  await update.callback_query.edit_message_text(welcome_text, reply_markup=reply_markup)
686
  except Exception as e:
687
  logger.error(f"Error editing message in start: {e}")
688
- # إذا فشل التعديل (ربما الرسالة قديمة), أرسل رسالة جديدة
689
  await update.effective_message.reply_text(welcome_text, reply_markup=reply_markup)
690
  else:
691
  await update.message.reply_text(welcome_text, reply_markup=reply_markup)
@@ -695,7 +637,6 @@ class MedicalLabBot:
695
  def create_subjects_keyboard(self):
696
  """إنشاء لوحة مفاتيح للمواد المتاحة"""
697
  keyboard = []
698
- # عرض المواد في عمودين إذا كان عددها أكبر من 4
699
  subjects = sorted(self.available_materials.keys())
700
  row = []
701
  max_cols = 2 if len(subjects) > 4 else 1
@@ -724,7 +665,7 @@ class MedicalLabBot:
724
  return await self.handle_general_help(query, context)
725
  elif callback_data == "refresh_materials":
726
  await query.edit_message_text("🔄 جاري تحديث قائمة المواد...")
727
- self.load_all_materials() # إعادة تحميل المواد
728
  keyboard = self.create_subjects_keyboard()
729
  reply_markup = InlineKeyboardMarkup(keyboard)
730
  await query.edit_message_text("✅ تم تحديث قائمة المواد.\nاختر المادة:", reply_markup=reply_markup)
@@ -736,11 +677,10 @@ class MedicalLabBot:
736
  memory = self.get_user_memory(user_id)
737
  memory['last_subject'] = subject
738
 
739
- # التأكد من أن المادة موجودة (احتياطي)
740
  if subject not in self.available_materials:
741
  logger.error(f"Selected subject '{subject}' not found in available materials.")
742
  await query.edit_message_text("❌ خطأ: المادة المحددة غير موجودة. ربما تحتاج لتحديث القائمة؟")
743
- return await self.start(update, context) # العودة للقائمة الرئيسية
744
 
745
  subject_files = self.available_materials[subject]['files']
746
  subject_name = subject.replace('_', ' ').title()
@@ -771,7 +711,7 @@ class MedicalLabBot:
771
  subject = context.user_data.get('current_subject')
772
  if not subject or subject not in self.available_materials:
773
  logger.warning("Subject context lost or invalid in handle_back_actions. Returning to main menu.")
774
- return await self.start(update, context) # إذا ضاع السياق، عد للبداية
775
 
776
  subject_files = self.available_materials[subject]['files']
777
  subject_name = subject.replace('_', ' ').title()
@@ -797,7 +737,7 @@ class MedicalLabBot:
797
  query = update.callback_query
798
  await query.answer()
799
  logger.info(f"User {query.from_user.id} requested main menu.")
800
- context.user_data.clear() # تنظيف السياق عند العودة للرئيسية
801
  return await self.start(update, context)
802
 
803
  async def handle_more_questions(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -814,7 +754,7 @@ class MedicalLabBot:
814
  reply_markup = InlineKeyboardMarkup(keyboard)
815
 
816
  await query.edit_message_text(f"💬 تفضل، اكتب سؤالك أو طلبك الجديد المتعلق بمادة '{subject}':", reply_markup=reply_markup)
817
- context.user_data['waiting_for'] = 'general' # ضبط الحالة لانتظار استعلام عام
818
  return WAITING_FOR_QUESTION
819
 
820
  async def handle_change_subject(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -842,7 +782,6 @@ class MedicalLabBot:
842
  await query.edit_message_text("❌ حدث خطأ في السياق. يرجى البدء من جديد.")
843
  return await self.start(update, context)
844
 
845
- # زر العودة للقائمة الرئيسية
846
  back_button = InlineKeyboardButton("🔙 رجوع", callback_data="back_to_actions")
847
  keyboard_with_back = [[back_button]]
848
  reply_markup_back = InlineKeyboardMarkup(keyboard_with_back)
@@ -853,18 +792,16 @@ class MedicalLabBot:
853
  elif action == "browse_files":
854
  files_text = await self.get_files_list_text(subject)
855
  keyboard = [
856
- # يمكن إضافة زر لشرح ملف من هنا مباشرة إذا أردت
857
  [InlineKeyboardButton("📖 طلب شرح ملف محدد", callback_data="explain_lecture")],
858
  [back_button]
859
  ]
860
  reply_markup = InlineKeyboardMarkup(keyboard)
861
- await query.edit_message_text(files_text, reply_markup=reply_markup, parse_mode='Markdown') # إضافة parse_mode
862
- # البقاء في نفس الحالة لعرض الخيارات مرة أخرى
863
  return SELECTING_ACTION
864
 
865
  elif action == "explain_lecture":
866
  files_list = await self.get_files_list_text(subject)
867
- if files_list.startswith("❌"): # إذا لم توجد ملفات
868
  await query.edit_message_text(files_list, reply_markup=reply_markup_back)
869
  return SELECTING_ACTION
870
 
@@ -873,7 +810,7 @@ class MedicalLabBot:
873
  f"{files_list}\n\n"
874
  f"📝 اكتب رقم الملف من القائمة أعلاه أو جزءاً من اسمه:",
875
  reply_markup=reply_markup_back,
876
- parse_mode='Markdown' # إضافة parse_mode
877
  )
878
  context.user_data['waiting_for'] = 'lecture_explanation'
879
  return WAITING_FOR_QUESTION
@@ -881,9 +818,8 @@ class MedicalLabBot:
881
  elif action == "generate_questions":
882
  await query.edit_message_text("⏳ حسناً، جاري توليد بعض الأسئلة للمراجعة...", reply_markup=reply_markup_back)
883
  questions = await self.generate_questions_for_subject(subject, user_id)
884
- # await query.edit_message_text(questions, reply_markup=reply_markup_back) # لا تعدل الرسالة، أرسل الأسئلة كرد
885
- await query.message.reply_text(questions, reply_markup=reply_markup_back, parse_mode='Markdown') # ارسال رد جديد + parse_mode
886
- return SELECTING_ACTION # العودة لقائمة الخيارات
887
 
888
  elif action == "explain_concept":
889
  await query.edit_message_text(
@@ -898,20 +834,13 @@ class MedicalLabBot:
898
  elif action == "summarize_content":
899
  await query.edit_message_text("⏳ تمام، جاري تلخيص ملف مهم من المادة...", reply_markup=reply_markup_back)
900
  summary = await self.generate_summary(subject, user_id)
901
- # await query.edit_message_text(summary, reply_markup=reply_markup_back) # لا تعدل، أرسل كرد
902
- await query.message.reply_text(summary, reply_markup=reply_markup_back, parse_mode='Markdown') # ارسال رد + parse_mode
903
- return SELECTING_ACTION # العودة لقائمة الخيارات
904
 
905
- # إذا لم يتعرف على الإجراء (احتياطي)
906
  logger.warning(f"Unknown action selected: {action}")
907
  await query.message.reply_text("عذراً، لم أتعرف على هذا الخيار.")
908
  return SELECTING_ACTION
909
 
910
- async def browse_available_files(self, query, context):
911
- """عرض الملفات المتاحة للمادة - تم دمجه في handle_action_selection"""
912
- # هذه الدالة لم تعد مستخدمة بشكل مباشر، الكود موجود في handle_action_selection
913
- pass
914
-
915
  async def get_files_list_text(self, subject):
916
  """إنشاء نص لقائمة الملفات"""
917
  if subject not in self.available_materials:
@@ -924,7 +853,6 @@ class MedicalLabBot:
924
  return "❌ لا توجد ملفات متاحة لهذه المادة بعد."
925
 
926
  files_text = f"📁 **الملفات المتاحة لمادة {subject}:**\n\n"
927
- # عرض عدد محدود من الملفات لتجنب رسالة طويلة جداً
928
  max_files_to_show = 25
929
  for i, file_info in enumerate(files[:max_files_to_show], 1):
930
  file_name = file_info['name']
@@ -940,8 +868,6 @@ class MedicalLabBot:
940
  }.get(file_type, '📄')
941
 
942
  num_text = f" (محاضرة {lecture_num})" if lecture_num else ""
943
- # تنسيق السطر: الرقم. ايموجي اسم_الملف (رقم المحاضرة إن وجد) - استخدام Markdown للأسماء
944
- # استبدال الشرطة السفلية بمسافة في اسم الملف للعرض فقط
945
  display_name = file_name.replace("_", " ")
946
  files_text += f"{i}. {type_emoji} `{display_name}`{num_text}\n"
947
 
@@ -953,7 +879,7 @@ class MedicalLabBot:
953
  async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
954
  """معالجة الرسائل النصية من المستخدم"""
955
  if not update.message or not update.message.text:
956
- return # تجاهل الرسائل غير النصية أو الفارغة
957
 
958
  user_message = update.message.text
959
  user_id = update.effective_user.id
@@ -963,26 +889,24 @@ class MedicalLabBot:
963
 
964
  await update.message.chat.send_action(action="typing")
965
 
966
- response = "عذراً، لم أفهم طلبك. هل يمكنك توضيحه؟" # رد افتراضي
967
- next_state = WAITING_FOR_QUESTION # البقاء في حالة الانتظار افتراضياً
968
 
969
  try:
970
  if waiting_for == 'lecture_explanation':
971
  response = await self.explain_lecture(user_message, subject, user_id)
972
- next_state = SELECTING_ACTION # تم الرد، العودة للخيارات
973
  elif waiting_for == 'concept_explanation':
974
  response = await self.explain_concept(user_message, subject, user_id)
975
- next_state = SELECTING_ACTION # تم الرد، العودة للخيارات
976
- else: # يتضمن 'general' أو أي شيء آخر (مثل سؤال عام بعد الضغط على "أسئلة أخرى")
977
  response = await self.process_general_query(user_message, subject, user_id)
978
- next_state = SELECTING_ACTION # تم الرد، العودة للخيارات
979
 
980
- # إرسال الرد
981
  await update.message.reply_text(response, parse_mode='Markdown')
982
 
983
- # إذا نجح الرد، أرسل قائمة الخيارات التالية
984
  if next_state == SELECTING_ACTION:
985
- context.user_data['waiting_for'] = None # إنهاء حالة الانتظار
986
  keyboard = [
987
  [InlineKeyboardButton("🔄 طرح سؤال آخر", callback_data="more_questions")],
988
  [InlineKeyboardButton("📚 تغيير المادة", callback_data="change_subject")],
@@ -991,13 +915,13 @@ class MedicalLabBot:
991
  reply_markup = InlineKeyboardMarkup(keyboard)
992
  await update.message.reply_text("هل تحتاج مساعدة أخرى؟", reply_markup=reply_markup)
993
 
994
- return next_state # العودة للحالة المناسبة (إما خيارات أو انتظار سؤال آخر)
995
 
996
  except Exception as e:
997
  logger.error(f"❌ Error processing message from user {user_id}: {e}", exc_info=True)
998
  await update.message.reply_text("❌ حدث خطأ غير متوقع أثناء معالجة طلبك. لقد تم تسجيل الخطأ. يرجى المحاولة مرة أخرى أو اختيار خيار آخر.")
999
- context.user_data['waiting_for'] = None # إنهاء الانتظار عند حدوث خطأ فادح
1000
- return SELECTING_ACTION # العودة لقائمة الخيارات كحل احتياطي آمن
1001
 
1002
  # ========== إنشاء كائن البوت ==========
1003
  bot = MedicalLabBot()
@@ -1010,17 +934,16 @@ async def root():
1010
  materials_count = len(bot.available_materials)
1011
  total_files = sum(len(material['files']) for material in bot.available_materials.values())
1012
 
1013
- # تحديد رسالة الحالة بناءً على حالة التهيئة
1014
  status_message = "⏳ جاري التهيئة (الاتصال بـ Telegram)..."
1015
- status_color = "#ffc107" # أصفر
1016
  init_details = ""
1017
  if bot.initialization_status == "success":
1018
  status_message = "✅ نشط ومهيأ"
1019
- status_color = "#28a745" # أخضر
1020
  init_details = "تم الاتصال بـ Telegram وضبط الويب هوك بنجاح."
1021
  elif bot.initialization_status == "failed":
1022
  status_message = "❌ فشل التهيئة"
1023
- status_color = "#dc3545" # أحمر
1024
  init_details = "فشل الاتصال بـ Telegram أو ضبط الويب هوك بعد عدة محاولات."
1025
 
1026
  return f"""
@@ -1072,23 +995,18 @@ async def root():
1072
  async def handle_telegram_update(request: Request):
1073
  """معالجة تحديثات Telegram"""
1074
  try:
1075
- # التحقق من أن التطبيق مهيأ
1076
  if not bot.is_initialized or not bot.application:
1077
  logger.error("❌ التطبيق غير مهيأ، لا يمكن معالجة التحديث (ربما لا يزال قيد التهيئة).")
1078
- # إرجاع 503 Service Unavailable لإخبار Telegram بإعادة المحاولة لاحقًا
1079
  return JSONResponse(
1080
  status_code=503,
1081
  content={"status": "error", "detail": "Application not initialized or still initializing"}
1082
  )
1083
 
1084
  update_data = await request.json()
1085
- # logger.debug(f"Received update: {update_data}") # إلغاء التعليق للتتبع المكثف
1086
  update = Update.de_json(update_data, bot.application.bot)
1087
 
1088
- # معالجة التحديث في مهمة منفصلة لتجنب حظر الحلقة الرئيسية
1089
  asyncio.create_task(bot.application.process_update(update))
1090
 
1091
- # إرجاع استجابة سريعة لـ Telegram
1092
  return JSONResponse(content={"status": "ok"})
1093
 
1094
  except json.JSONDecodeError:
@@ -1099,7 +1017,6 @@ async def handle_telegram_update(request: Request):
1099
  )
1100
  except Exception as e:
1101
  logger.error(f"❌ Error processing update: {e}", exc_info=True)
1102
- # إرجاع خطأ عام 500 Internal Server Error
1103
  return JSONResponse(
1104
  status_code=500,
1105
  content={"status": "error", "detail": "Internal server error while processing update"}
@@ -1111,18 +1028,16 @@ async def health_check():
1111
  materials_count = len(bot.available_materials)
1112
  total_files = sum(len(material['files']) for material in bot.available_materials.values())
1113
 
1114
- status_code = 503 # Service Unavailable افتراضياً
1115
  service_status = "unhealthy"
1116
  if bot.initialization_status == "pending":
1117
  service_status = "initializing"
1118
- # يمكن إرجاع 200 هنا إذا أردت أن تعتبره "صحي" أثناء التهيئة
1119
- # status_code = 200
1120
  elif bot.initialization_status == "success":
1121
  service_status = "healthy"
1122
- status_code = 200 # OK
1123
  elif bot.initialization_status == "failed":
1124
  service_status = "unhealthy_failed_init"
1125
- status_code = 500 # Internal Server Error
1126
 
1127
  response_payload = {
1128
  "status": service_status,
@@ -1133,19 +1048,17 @@ async def health_check():
1133
  "timestamp": datetime.now().isoformat()
1134
  }
1135
 
1136
- # استخدام JSONResponse لتحديد status_code بشكل صحيح
1137
  return JSONResponse(content=response_payload, status_code=status_code)
1138
 
1139
  # ========== التشغيل الرئيسي ==========
1140
  if __name__ == "__main__":
1141
  port = int(os.environ.get("PORT", 7860))
1142
- # يمكن تحديد مستوى السجل من متغير بيئة أيضاً
1143
  log_level = os.environ.get("LOG_LEVEL", "info").lower()
1144
  logger.info(f"🚀 Starting Medical Lab Bot on port {port} with log level {log_level}")
1145
  uvicorn.run(
1146
- "app:app", # تغيير لاستخدام صيغة uvicorn القياسية
1147
  host="0.0.0.0",
1148
  port=port,
1149
  log_level=log_level,
1150
- reload=False # تعطيل إعادة التحميل التلقائي في الإنتاج
1151
  )
 
14
  from PIL import Image
15
  import io
16
  import requests
17
+ from fastapi import FastAPI, Request
18
+ from fastapi.responses import HTMLResponse, JSONResponse
19
  import uvicorn
20
  import random
21
+ import docx
22
 
23
  # ========== إضافة المكتبات الصحيحة لحل مشكلة DNS + Lifespan + Typing ==========
24
  import httpx
25
  import dns.asyncresolver
26
+ from httpx import AsyncClient, AsyncHTTPTransport
27
+ import contextlib
 
28
  # ==========================================================
29
 
30
  # ========== تكوين السجلات ==========
 
60
  self.resolver.nameservers = ['1.1.1.1', '8.8.8.8']
61
  logger.info("🔧 CustomDNSTransport initialized with 1.1.1.1 and 8.8.8.8")
62
 
63
+ async def handle_async_request(self, request):
64
  try:
65
  host = request.url.host
66
 
67
  # التحقق إذا كان Host هو أصلاً IP
68
  is_ip = True
69
  try:
 
70
  parts = host.split('.')
71
  if len(parts) != 4 or not all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
72
  is_ip = False
 
73
  except Exception:
74
  is_ip = False
75
 
76
  if is_ip:
 
77
  pass
78
  else:
79
  logger.info(f"🔧 Resolving host: {host}")
 
81
  ip = result[0].address
82
  logger.info(f"✅ Resolved {host} to {ip}")
83
 
 
84
  request.extensions["sni_hostname"] = host
 
85
  request.url = request.url.copy_with(host=ip)
86
 
87
  except dns.resolver.NoAnswer:
 
90
  logger.error(f"❌ DNS NXDOMAIN for {host}. Trying request with original host...")
91
  except Exception as e:
92
  logger.error(f"❌ DNS resolution failed for {host}: {e}. Trying request with original host...")
 
93
  pass
94
 
 
95
  return await super().handle_async_request(request)
96
  # ==========================================================
97
 
 
116
  self.file_cache = {}
117
  self.application = None
118
  self.is_initialized = False
119
+ self.initialization_status = "pending"
120
  self.load_all_materials()
121
 
122
  async def initialize_application(self):
 
127
 
128
  logger.info("🔄 جاري تهيئة تطبيق التليجرام...")
129
 
 
 
130
  custom_transport = CustomDNSTransport()
131
  custom_client = httpx.AsyncClient(transport=custom_transport)
 
132
 
133
  self.application = (
134
  Application.builder()
135
  .token(TELEGRAM_BOT_TOKEN)
136
+ .http_client(custom_client)
137
  .build()
138
  )
139
  await self.setup_handlers()
 
144
  for attempt in range(max_retries):
145
  try:
146
  logger.info(f"🚀 محاولة تهيئة الاتصال بـ Telegram (محاولة {attempt + 1}/{max_retries})...")
 
147
  await self.application.initialize()
148
  logger.info("✅ تم تهيئة الاتصال بـ Telegram بنجاح.")
149
 
 
150
  if SPACE_URL:
151
  webhook_url = f"{SPACE_URL.rstrip('/')}/telegram"
152
  logger.info(f"���️ جاري إعداد الويب هوك على: {webhook_url}")
 
159
  else:
160
  logger.warning("⚠️ SPACE_URL not set. Webhook cannot be set.")
161
 
 
162
  self.is_initialized = True
163
  self.initialization_status = "success"
164
  logger.info("✅✅✅ التطبيق جاهز لاستقبال الطلبات.")
165
+ return True
166
 
167
  except Exception as e:
168
  logger.warning(f"⚠️ فشلت محاولة التهيئة {attempt + 1}: {e}")
 
170
  logger.info(f"⏳ الانتظار {retry_delay} ثواني قبل إعادة المحاولة...")
171
  await asyncio.sleep(retry_delay)
172
  else:
173
+ logger.error(f"❌ فشل تهيئة التطبيق نهائياً بعد {max_retries} محاولات.")
174
 
 
175
  self.is_initialized = False
176
  self.initialization_status = "failed"
177
  return False
178
 
179
  except Exception as e:
180
+ logger.error(f"❌ خطأ فادح في تهيئة التطبيق: {e}", exc_info=True)
181
  self.is_initialized = False
182
  self.initialization_status = "failed"
183
  return False
184
 
185
  async def setup_handlers(self):
186
  """إعداد معالجات التليجرام"""
 
187
  conv_handler = ConversationHandler(
188
  entry_points=[CommandHandler('start', self.start)],
189
  states={
 
192
  ],
193
  SELECTING_ACTION: [
194
  CallbackQueryHandler(self.handle_action_selection, pattern='^(explain_lecture|browse_files|generate_questions|summarize_content|explain_concept|main_menu)$'),
 
195
  CallbackQueryHandler(self.handle_back_actions, pattern='^back_to_actions$')
196
  ],
197
  WAITING_FOR_QUESTION: [
 
208
  per_message=False
209
  )
210
 
 
211
  self.application.add_handler(conv_handler)
 
 
212
  self.application.add_handler(CallbackQueryHandler(self.handle_more_questions, pattern='^more_questions$'))
213
  self.application.add_handler(CallbackQueryHandler(self.handle_change_subject, pattern='^change_subject$'))
214
 
 
241
  materials[subject]['file_details'][file_name] = file_info
242
 
243
  else:
 
244
  if 'general' not in materials:
245
  materials['general'] = {
246
  'files': [],
 
254
  logger.error(f"خطأ في معالجة الملف {file_path}: {e}")
255
  continue
256
 
 
257
  for subject in materials:
258
  materials[subject]['files'].sort(key=lambda x: (x['lecture_number'] if x['lecture_number'] is not None else float('inf'), x['name']))
259
 
 
260
  self.available_materials = materials
261
  logger.info(f"✅ تم تحميل {len(materials)} مادة بنجاح")
262
 
263
  except Exception as e:
264
  logger.error(f"❌ خطأ في تحميل المواد: {e}")
265
+ self.available_materials = {'Biochemistry': {'files': [], 'file_details': {}}}
 
 
266
 
267
  def get_user_memory(self, user_id):
268
  """الحصول على ذاكرة المستخدم أو إنشاؤها"""
 
298
  lecture_num = None
299
  file_type = 'unknown'
300
 
 
 
301
  match = re.search(r'(?:lecture|lec|محاضرة)\s*(\d+)', name_lower)
302
  if not match:
 
303
  match = re.search(r'^(\d+)\s*-|[\s_-](\d+)$', name_lower)
304
  if match:
 
305
  lecture_num_str = match.group(2) or match.group(1)
306
  lecture_num = int(lecture_num_str) if lecture_num_str else None
307
  else:
308
  lecture_num = int(match.group(1))
309
 
 
310
  if 'lab' in name_lower or 'عملي' in name_lower:
311
  file_type = 'lab'
312
  elif 'exam' in name_lower or 'امتحان' in name_lower or 'اسئلة' in name_lower:
 
315
  file_type = 'summary'
316
  elif 'lecture' in name_lower or 'محاضرة' in name_lower:
317
  file_type = 'lecture'
 
318
  elif lecture_num is not None:
319
  file_type = 'lecture'
320
 
 
328
  async def _call_nvidia_api(self, messages, max_tokens=1500):
329
  """دالة مساعدة لاستدعاء NVIDIA API"""
330
  try:
 
331
  completion = await asyncio.to_thread(
332
  nvidia_client.chat.completions.create,
333
+ model="meta/llama3-70b-instruct",
334
  messages=messages,
335
  temperature=0.5,
336
  top_p=1,
 
346
  files = self.available_materials[subject]['files']
347
  query_lower = query.lower().strip()
348
 
 
349
  try:
350
+ match_num = re.findall(r'^\d+$', query_lower)
 
351
  if match_num:
352
  index = int(match_num[0])
353
  if 1 <= index <= len(files):
354
  logger.info(f"Found file by index {index}")
355
  return files[index - 1]
356
  except (IndexError, ValueError):
357
+ pass
358
 
 
359
  match_lec_num = re.search(r'(?:lecture|lec|محاضرة)\s*(\d+)', query_lower)
360
  if match_lec_num:
361
  num = int(match_lec_num.group(1))
 
364
  logger.info(f"Found file by exact lecture number {num}")
365
  return file_info
366
 
 
367
  best_match = None
368
  highest_score = 0
369
  for file_info in files:
370
  name_lower = file_info['name'].lower()
 
371
  query_words = set(query_lower.split())
372
+ name_words = set(re.findall(r'\w+', name_lower))
373
  common_words = query_words.intersection(name_words)
374
  score = len(common_words)
375
 
376
  if score > highest_score:
377
  highest_score = score
378
  best_match = file_info
 
379
  elif highest_score > 0 and file_info['lecture_number'] is not None:
380
  num_in_query = re.findall(r'\d+', query_lower)
381
  if num_in_query and file_info['lecture_number'] == int(num_in_query[0]):
 
387
  return best_match
388
 
389
  logger.warning(f"Could not find file matching query: '{query}' in subject: {subject}")
390
+ return None
391
 
392
  async def download_and_extract_content(self, file_path, subject):
393
  """تحميل الملف من HF واستخراج النص منه"""
394
  if file_path in self.file_cache:
 
395
  return self.file_cache[file_path]
396
 
397
  logger.info(f"⏳ Downloading {file_path} from Hugging Face...")
398
  try:
 
399
  local_path = await asyncio.to_thread(
400
  hf_hub_download,
401
  repo_id=REPO_ID,
 
407
  logger.info(f"📄 Extracting content from {local_path}")
408
 
409
  if local_path.lower().endswith('.pdf'):
 
410
  with fitz.open(local_path) as doc:
411
  for page_num, page in enumerate(doc):
412
+ text_content += page.get_text("text", sort=True)
 
413
 
414
  elif local_path.lower().endswith('.txt'):
415
+ with open(local_path, 'r', encoding='utf-8', errors='ignore') as f:
416
  text_content = f.read()
417
 
418
  elif local_path.lower().endswith('.docx'):
419
+ doc_obj = await asyncio.to_thread(docx.Document, local_path)
420
  full_text = []
421
  for para in doc_obj.paragraphs:
422
  full_text.append(para.text)
 
426
  logger.warning(f"Unsupported file type: {local_path}")
427
  return f"Error: Unsupported file type ({os.path.basename(file_path)})."
428
 
429
+ text_content = re.sub(r'\s+\n', '\n', text_content)
430
+ text_content = re.sub(r'\n{3,}', '\n\n', text_content)
431
+ text_content = re.sub(r' +', ' ', text_content).strip()
 
432
 
433
  if len(text_content) < 50:
434
  logger.warning(f"File {file_path} content is very short or empty after extraction.")
435
 
436
  self.file_cache[file_path] = text_content
 
437
  return text_content
438
 
439
  except Exception as e:
440
  logger.error(f"❌ Error downloading/extracting {file_path}: {e}", exc_info=True)
441
  return f"Error: Could not retrieve or process file {os.path.basename(file_path)}."
442
 
 
 
443
  async def explain_lecture(self, user_query, subject, user_id):
444
  """شرح محاضرة بناءً على استعلام المستخدم"""
445
  file_info = self._find_file_by_query(user_query, subject)
 
462
  memory = self.get_user_memory(user_id)
463
  memory['history'].append({"role": "user", "content": f"اشرح لي النقاط الأساسية في هذه المحاضرة: {file_name}"})
464
 
465
+ max_content_chars = 7000
 
466
  if len(content) > max_content_chars:
467
  content_snippet = content[:max_content_chars] + "\n\n[... المحتوى مقطوع ...]"
468
  logger.warning(f"Content for {file_name} truncated to {max_content_chars} chars.")
 
489
 
490
  messages = [
491
  {"role": "system", "content": system_prompt},
492
+ *memory['history'][-3:-1],
 
493
  {"role": "user", "content": f"اشرح لي المفهوم التالي: {user_query}"}
494
  ]
495
 
496
+ response = await self._call_nvidia_api(messages, 1000)
497
  memory['history'].append({"role": "assistant", "content": response})
498
  return f"🧪 **شرح مفهوم: {user_query}**\n\n{response}"
499
 
 
505
  system_prompt = f"أنت مساعد ذكي متخصص في المختبرات الطبية. المادة الدراسية الحالية التي يركز عليها الطالب هي '{subject}'. أجب على سؤال الطالب ({user_message}) إجابة واضحة ومباشرة. استخدم سياق المحادثة السابق إذا كان ضرورياً لفهم السؤال. إذا كان السؤال خارج نطاق المادة أو التخصص، اعتذر بلطف."
506
 
507
  messages = [{"role": "system", "content": system_prompt}]
 
508
  messages.extend(history[-4:])
509
  messages.append({"role": "user", "content": user_message})
510
 
 
519
  return "❌ لا توجد ملفات في هذه المادة لتوليد أسئلة منها."
520
 
521
  files = self.available_materials[subject]['files']
 
522
  preferred_files = [f for f in files if f['type'] in ('lecture', 'summary')]
523
  if preferred_files:
524
  file_info = random.choice(preferred_files)
525
  else:
526
+ file_info = random.choice(files)
527
 
528
  file_path = file_info['path']
529
  file_name = file_info['name']
 
533
 
534
  if content.startswith("Error:") or not content.strip():
535
  logger.error(f"Failed to get content for question generation from {file_name}. Content: {content[:100]}")
 
536
  if len(files) > 1:
537
  logger.info("Retrying with another file for question generation...")
538
  remaining_files = [f for f in files if f['path'] != file_path]
 
569
 
570
  files = self.available_materials[subject]['files']
571
 
 
572
  summary_file = next((f for f in files if f['type'] == 'summary'), None)
 
573
  if not summary_file:
574
  summary_file = next((f for f in files if f['type'] == 'lecture'), None)
 
575
  if not summary_file:
576
  summary_file = files[0]
577
 
 
598
  response = await self._call_nvidia_api(messages)
599
  return f"📋 **ملخص ملف: {file_name}**\n\n{response}"
600
 
 
 
601
  async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
602
  """بدء المحادثة وعرض القائمة الرئيسية"""
603
  user_id = update.effective_user.id
 
614
  if not self.available_materials:
615
  welcome_text += "\n\n⚠️ عذراً، لم أتمكن من تحميل أي مواد دراسية حالياً. حاول تحديث القائمة بالضغط على (🔄)."
616
  else:
617
+ for subject in sorted(self.available_materials.keys()):
618
  file_count = len(self.available_materials[subject]['files'])
619
  welcome_text += f"\n• {subject} ({file_count} ملف)"
620
 
 
623
  keyboard = self.create_subjects_keyboard()
624
  reply_markup = InlineKeyboardMarkup(keyboard)
625
 
 
626
  if update.callback_query:
627
  try:
628
  await update.callback_query.edit_message_text(welcome_text, reply_markup=reply_markup)
629
  except Exception as e:
630
  logger.error(f"Error editing message in start: {e}")
 
631
  await update.effective_message.reply_text(welcome_text, reply_markup=reply_markup)
632
  else:
633
  await update.message.reply_text(welcome_text, reply_markup=reply_markup)
 
637
  def create_subjects_keyboard(self):
638
  """إنشاء لوحة مفاتيح للمواد المتاحة"""
639
  keyboard = []
 
640
  subjects = sorted(self.available_materials.keys())
641
  row = []
642
  max_cols = 2 if len(subjects) > 4 else 1
 
665
  return await self.handle_general_help(query, context)
666
  elif callback_data == "refresh_materials":
667
  await query.edit_message_text("🔄 جاري تحديث قائمة المواد...")
668
+ self.load_all_materials()
669
  keyboard = self.create_subjects_keyboard()
670
  reply_markup = InlineKeyboardMarkup(keyboard)
671
  await query.edit_message_text("✅ تم تحديث قائمة المواد.\nاختر المادة:", reply_markup=reply_markup)
 
677
  memory = self.get_user_memory(user_id)
678
  memory['last_subject'] = subject
679
 
 
680
  if subject not in self.available_materials:
681
  logger.error(f"Selected subject '{subject}' not found in available materials.")
682
  await query.edit_message_text("❌ خطأ: المادة المحددة غير موجودة. ربما تحتاج لتحديث القائمة؟")
683
+ return await self.start(update, context)
684
 
685
  subject_files = self.available_materials[subject]['files']
686
  subject_name = subject.replace('_', ' ').title()
 
711
  subject = context.user_data.get('current_subject')
712
  if not subject or subject not in self.available_materials:
713
  logger.warning("Subject context lost or invalid in handle_back_actions. Returning to main menu.")
714
+ return await self.start(update, context)
715
 
716
  subject_files = self.available_materials[subject]['files']
717
  subject_name = subject.replace('_', ' ').title()
 
737
  query = update.callback_query
738
  await query.answer()
739
  logger.info(f"User {query.from_user.id} requested main menu.")
740
+ context.user_data.clear()
741
  return await self.start(update, context)
742
 
743
  async def handle_more_questions(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
 
754
  reply_markup = InlineKeyboardMarkup(keyboard)
755
 
756
  await query.edit_message_text(f"💬 تفضل، اكتب سؤالك أو طلبك الجديد المتعلق بمادة '{subject}':", reply_markup=reply_markup)
757
+ context.user_data['waiting_for'] = 'general'
758
  return WAITING_FOR_QUESTION
759
 
760
  async def handle_change_subject(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
 
782
  await query.edit_message_text("❌ حدث خطأ في السياق. يرجى البدء من جديد.")
783
  return await self.start(update, context)
784
 
 
785
  back_button = InlineKeyboardButton("🔙 رجوع", callback_data="back_to_actions")
786
  keyboard_with_back = [[back_button]]
787
  reply_markup_back = InlineKeyboardMarkup(keyboard_with_back)
 
792
  elif action == "browse_files":
793
  files_text = await self.get_files_list_text(subject)
794
  keyboard = [
 
795
  [InlineKeyboardButton("📖 طلب شرح ملف محدد", callback_data="explain_lecture")],
796
  [back_button]
797
  ]
798
  reply_markup = InlineKeyboardMarkup(keyboard)
799
+ await query.edit_message_text(files_text, reply_markup=reply_markup, parse_mode='Markdown')
 
800
  return SELECTING_ACTION
801
 
802
  elif action == "explain_lecture":
803
  files_list = await self.get_files_list_text(subject)
804
+ if files_list.startswith("❌"):
805
  await query.edit_message_text(files_list, reply_markup=reply_markup_back)
806
  return SELECTING_ACTION
807
 
 
810
  f"{files_list}\n\n"
811
  f"📝 اكتب رقم الملف من القائمة أعلاه أو جزءاً من اسمه:",
812
  reply_markup=reply_markup_back,
813
+ parse_mode='Markdown'
814
  )
815
  context.user_data['waiting_for'] = 'lecture_explanation'
816
  return WAITING_FOR_QUESTION
 
818
  elif action == "generate_questions":
819
  await query.edit_message_text("⏳ حسناً، جاري توليد بعض الأسئلة للمراجعة...", reply_markup=reply_markup_back)
820
  questions = await self.generate_questions_for_subject(subject, user_id)
821
+ await query.message.reply_text(questions, reply_markup=reply_markup_back, parse_mode='Markdown')
822
+ return SELECTING_ACTION
 
823
 
824
  elif action == "explain_concept":
825
  await query.edit_message_text(
 
834
  elif action == "summarize_content":
835
  await query.edit_message_text("⏳ تمام، جاري تلخيص ملف مهم من المادة...", reply_markup=reply_markup_back)
836
  summary = await self.generate_summary(subject, user_id)
837
+ await query.message.reply_text(summary, reply_markup=reply_markup_back, parse_mode='Markdown')
838
+ return SELECTING_ACTION
 
839
 
 
840
  logger.warning(f"Unknown action selected: {action}")
841
  await query.message.reply_text("عذراً، لم أتعرف على هذا الخيار.")
842
  return SELECTING_ACTION
843
 
 
 
 
 
 
844
  async def get_files_list_text(self, subject):
845
  """إنشاء نص لقائمة الملفات"""
846
  if subject not in self.available_materials:
 
853
  return "❌ لا توجد ملفات متاحة لهذه المادة بعد."
854
 
855
  files_text = f"📁 **الملفات المتاحة لمادة {subject}:**\n\n"
 
856
  max_files_to_show = 25
857
  for i, file_info in enumerate(files[:max_files_to_show], 1):
858
  file_name = file_info['name']
 
868
  }.get(file_type, '📄')
869
 
870
  num_text = f" (محاضرة {lecture_num})" if lecture_num else ""
 
 
871
  display_name = file_name.replace("_", " ")
872
  files_text += f"{i}. {type_emoji} `{display_name}`{num_text}\n"
873
 
 
879
  async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
880
  """معالجة الرسائل النصية من المستخدم"""
881
  if not update.message or not update.message.text:
882
+ return
883
 
884
  user_message = update.message.text
885
  user_id = update.effective_user.id
 
889
 
890
  await update.message.chat.send_action(action="typing")
891
 
892
+ response = "عذراً، لم أفهم طلبك. هل يمكنك توضيحه؟"
893
+ next_state = WAITING_FOR_QUESTION
894
 
895
  try:
896
  if waiting_for == 'lecture_explanation':
897
  response = await self.explain_lecture(user_message, subject, user_id)
898
+ next_state = SELECTING_ACTION
899
  elif waiting_for == 'concept_explanation':
900
  response = await self.explain_concept(user_message, subject, user_id)
901
+ next_state = SELECTING_ACTION
902
+ else:
903
  response = await self.process_general_query(user_message, subject, user_id)
904
+ next_state = SELECTING_ACTION
905
 
 
906
  await update.message.reply_text(response, parse_mode='Markdown')
907
 
 
908
  if next_state == SELECTING_ACTION:
909
+ context.user_data['waiting_for'] = None
910
  keyboard = [
911
  [InlineKeyboardButton("🔄 طرح سؤال آخر", callback_data="more_questions")],
912
  [InlineKeyboardButton("📚 تغيير المادة", callback_data="change_subject")],
 
915
  reply_markup = InlineKeyboardMarkup(keyboard)
916
  await update.message.reply_text("هل تحتاج مساعدة أخرى؟", reply_markup=reply_markup)
917
 
918
+ return next_state
919
 
920
  except Exception as e:
921
  logger.error(f"❌ Error processing message from user {user_id}: {e}", exc_info=True)
922
  await update.message.reply_text("❌ حدث خطأ غير متوقع أثناء معالجة طلبك. لقد تم تسجيل الخطأ. يرجى المحاولة مرة أخرى أو اختيار خيار آخر.")
923
+ context.user_data['waiting_for'] = None
924
+ return SELECTING_ACTION
925
 
926
  # ========== إنشاء كائن البوت ==========
927
  bot = MedicalLabBot()
 
934
  materials_count = len(bot.available_materials)
935
  total_files = sum(len(material['files']) for material in bot.available_materials.values())
936
 
 
937
  status_message = "⏳ جاري التهيئة (الاتصال بـ Telegram)..."
938
+ status_color = "#ffc107"
939
  init_details = ""
940
  if bot.initialization_status == "success":
941
  status_message = "✅ نشط ومهيأ"
942
+ status_color = "#28a745"
943
  init_details = "تم الاتصال بـ Telegram وضبط الويب هوك بنجاح."
944
  elif bot.initialization_status == "failed":
945
  status_message = "❌ فشل التهيئة"
946
+ status_color = "#dc3545"
947
  init_details = "فشل الاتصال بـ Telegram أو ضبط الويب هوك بعد عدة محاولات."
948
 
949
  return f"""
 
995
  async def handle_telegram_update(request: Request):
996
  """معالجة تحديثات Telegram"""
997
  try:
 
998
  if not bot.is_initialized or not bot.application:
999
  logger.error("❌ التطبيق غير مهيأ، لا يمكن معالجة التحديث (ربما لا يزال قيد التهيئة).")
 
1000
  return JSONResponse(
1001
  status_code=503,
1002
  content={"status": "error", "detail": "Application not initialized or still initializing"}
1003
  )
1004
 
1005
  update_data = await request.json()
 
1006
  update = Update.de_json(update_data, bot.application.bot)
1007
 
 
1008
  asyncio.create_task(bot.application.process_update(update))
1009
 
 
1010
  return JSONResponse(content={"status": "ok"})
1011
 
1012
  except json.JSONDecodeError:
 
1017
  )
1018
  except Exception as e:
1019
  logger.error(f"❌ Error processing update: {e}", exc_info=True)
 
1020
  return JSONResponse(
1021
  status_code=500,
1022
  content={"status": "error", "detail": "Internal server error while processing update"}
 
1028
  materials_count = len(bot.available_materials)
1029
  total_files = sum(len(material['files']) for material in bot.available_materials.values())
1030
 
1031
+ status_code = 503
1032
  service_status = "unhealthy"
1033
  if bot.initialization_status == "pending":
1034
  service_status = "initializing"
 
 
1035
  elif bot.initialization_status == "success":
1036
  service_status = "healthy"
1037
+ status_code = 200
1038
  elif bot.initialization_status == "failed":
1039
  service_status = "unhealthy_failed_init"
1040
+ status_code = 500
1041
 
1042
  response_payload = {
1043
  "status": service_status,
 
1048
  "timestamp": datetime.now().isoformat()
1049
  }
1050
 
 
1051
  return JSONResponse(content=response_payload, status_code=status_code)
1052
 
1053
  # ========== التشغيل الرئيسي ==========
1054
  if __name__ == "__main__":
1055
  port = int(os.environ.get("PORT", 7860))
 
1056
  log_level = os.environ.get("LOG_LEVEL", "info").lower()
1057
  logger.info(f"🚀 Starting Medical Lab Bot on port {port} with log level {log_level}")
1058
  uvicorn.run(
1059
+ "app:app",
1060
  host="0.0.0.0",
1061
  port=port,
1062
  log_level=log_level,
1063
+ reload=False
1064
  )