#!/usr/bin/env python3 """ ╔══════════════════════════════════════════════════════════════════╗ ║ 📚 Book Analysis Pipeline — Phoenix AI ║ ║ تحليل الكتب وتحويلها إلى ملخصات ذكية باستخدام GLM-4.5 ║ ╚══════════════════════════════════════════════════════════════════╝ الخطوات: 1. تقسيم الكتاب (PDF Chunking) 2. استخراج النص أو تحويل الصفحات إلى صور 3. إرسال كل مقطع إلى GLM-4.5 عبر OpenRouter API 4. تجميع النتائج وحفظها بصيغة JSON الاستخدام: python book_pipeline.py "path/to/book.pdf" python book_pipeline.py "path/to/book.pdf" --output results.json python book_pipeline.py "path/to/book.pdf" --pages-per-chunk 5 --mode vision """ import os import sys import json import time import base64 import logging import argparse import re from pathlib import Path from typing import Optional from dataclasses import dataclass, field, asdict from io import BytesIO # ─── إصلاح ترميز Windows Console ─── if sys.platform == "win32": import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") # ─── تحميل المتغيرات من ملف .env ─── from dotenv import load_dotenv load_dotenv(Path(__file__).parent / ".env") # ─── إعداد نظام السجلات (Logging) ─── logging.basicConfig( level=logging.INFO, format="%(asctime)s │ %(levelname)-7s │ %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger("book_pipeline") # ══════════════════════════════════════════════════════════════════ # 📦 هياكل البيانات (Data Models) # ══════════════════════════════════════════════════════════════════ @dataclass class ChunkResult: """نتيجة تحليل مقطع واحد من الكتاب""" chunk_index: int pages: str # مثال: "1-5" cleaned_text: str = "" summary: str = "" key_points: list = field(default_factory=list) rating: dict = field(default_factory=lambda: {"score": "0/10", "reason": ""}) error: Optional[str] = None @dataclass class BookAnalysis: """النتيجة النهائية لتحليل الكتاب بالكامل""" book_path: str total_pages: int total_chunks: int processing_time_seconds: float chunks: list = field(default_factory=list) final_summary: str = "" overall_rating: dict = field(default_factory=lambda: {"score": "0/10", "reason": ""}) # ══════════════════════════════════════════════════════════════════ # 📄 الخطوة 1: تقسيم الكتاب (PDF Chunking) # ══════════════════════════════════════════════════════════════════ def extract_text_from_pdf(pdf_path: str) -> list[str]: """ استخراج النص من ملف PDF — صفحة بصفحة. يُرجع قائمة من النصوص (كل عنصر = صفحة واحدة). يستخدم PyMuPDF (fitz) لأنه سريع ودقيق. """ try: import fitz # PyMuPDF except ImportError as e: logger.error("❌ مكتبة PyMuPDF غير مثبتة. قم بتثبيتها: pip install PyMuPDF") raise ImportError("❌ مكتبة PyMuPDF غير مثبتة. قم بتثبيتها: pip install PyMuPDF") from e doc = fitz.open(pdf_path) pages_text = [] for page_num in range(len(doc)): page = doc[page_num] text = page.get_text("text").strip() pages_text.append(text) logger.debug(f" صفحة {page_num + 1}: {len(text)} حرف") doc.close() logger.info(f"📄 تم استخراج النص من {len(pages_text)} صفحة") return pages_text def convert_pdf_pages_to_images(pdf_path: str, dpi: int = 200) -> list[bytes]: """ تحويل صفحات PDF إلى صور PNG (للوضع Vision). يُرجع قائمة من البيانات الثنائية لكل صورة. """ try: import fitz # PyMuPDF except ImportError as e: logger.error("❌ مكتبة PyMuPDF غير مثبتة. قم بتثبيتها: pip install PyMuPDF") raise ImportError("❌ مكتبة PyMuPDF غير مثبتة. قم بتثبيتها: pip install PyMuPDF") from e doc = fitz.open(pdf_path) images = [] zoom = dpi / 72 # 72 هي الدقة الافتراضية for page_num in range(len(doc)): page = doc[page_num] mat = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=mat) img_bytes = pix.tobytes("png") images.append(img_bytes) logger.debug(f" صورة صفحة {page_num + 1}: {len(img_bytes) // 1024} KB") doc.close() logger.info(f"🖼️ تم تحويل {len(images)} صفحة إلى صور") return images def chunk_pages(items: list, pages_per_chunk: int) -> list[list]: """ تقسيم الصفحات إلى مجموعات (Chunks) لتجنب تجاوز حد الـ Context Window. Args: items: قائمة النصوص أو الصور pages_per_chunk: عدد الصفحات في كل مجموعة Returns: قائمة من المجموعات """ chunks = [] for i in range(0, len(items), pages_per_chunk): chunks.append(items[i:i + pages_per_chunk]) logger.info(f"✂️ تم تقسيم {len(items)} صفحة إلى {len(chunks)} مجموعة ({pages_per_chunk} صفحات/مجموعة)") return chunks # ══════════════════════════════════════════════════════════════════ # 🤖 الخطوة 2: الاتصال بـ GLM-4.5 API # ══════════════════════════════════════════════════════════════════ # ─── Prompt التحليل الداخلي ─── ANALYSIS_SYSTEM_PROMPT = """أنت محلل كتب خبير ومتخصص في تحليل النصوص العربية والإنجليزية. مهمتك هي تحليل النص المُرسل إليك والقيام بالمهام التالية: 1. **تنظيف النص**: صحّح أي أخطاء إملائية أو لغوية أو أخطاء ناتجة عن OCR. أعد صياغة الجمل المكسورة لتصبح مفهومة. 2. **كتابة ملخص مكثف**: اكتب ملخصاً شاملاً ومكثفاً للنص يغطي الأفكار الرئيسية. 3. **استخراج أهم النقاط**: استخرج أبرز النقاط والأفكار الرئيسية (Key Points) كقائمة. 4. **تقييم المحتوى**: أعطِ تقييماً من 10 مع ذكر السبب. ## قواعد مهمة: - إذا كان النص بالعربية، أجب بالعربية. إذا كان بالإنجليزية، أجب بالإنجليزية. - كن دقيقاً ومختصراً في الملخص. - النقاط الرئيسية يجب أن تكون واضحة ومحددة. - التقييم يجب أن يكون موضوعياً مع تبرير واضح. ## صيغة الإجابة: أجب **حصرياً** بصيغة JSON بالهيكل التالي (بدون أي نص إضافي خارج JSON): { "cleaned_text": "النص بعد التنظيف والتصحيح", "summary": "ملخص مكثف للمحتوى", "key_points": ["نقطة 1", "نقطة 2", "نقطة 3"], "rating": {"score": "X/10", "reason": "سبب التقييم"} }""" FINAL_SUMMARY_PROMPT = """أنت محلل كتب خبير. لديك مجموعة ملخصات لأجزاء مختلفة من كتاب واحد. اكتب ملخصاً نهائياً شاملاً يجمع كل هذه الأجزاء في ملخص واحد متكامل. كذلك أعطِ تقييماً نهائياً شاملاً للكتاب. أجب **حصرياً** بصيغة JSON: { "final_summary": "الملخص النهائي الشامل للكتاب", "overall_rating": {"score": "X/10", "reason": "سبب التقييم الشامل"} }""" class GLMClient: """ عميل للتواصل مع GLM-4.5 عبر OpenRouter API. يدعم الوضعين: النصي (text) والبصري (vision). """ # ════════════════════════════════════════════════ # ⬇️ ضع مفتاح الـ API الخاص بك هنا أو في ملف .env # ════════════════════════════════════════════════ BASE_URL = "https://openrouter.ai/api/v1/chat/completions" def __init__(self, api_key: str, model: str = "z-ai/glm-4.5-air"): """ Args: api_key: مفتاح OpenRouter API — يمكنك وضعه مباشرة هنا أو في ملف .env كـ OPENROUTER_API_KEY model: اسم النموذج على OpenRouter """ if not api_key: logger.error("❌ لم يتم توفير مفتاح API!") logger.error(" ضعه في ملف .env كـ OPENROUTER_API_KEY=sk-or-v1-...") logger.error(" أو مرره كـ argument: --api-key YOUR_KEY") raise ValueError("مفتاح OpenRouter API غير متوفر.") self.api_key = api_key self.model = model self._session = None @property def headers(self) -> dict: return { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", "HTTP-Referer": "https://phoenix-ai.app", } def _get_session(self): """إنشاء جلسة HTTP واحدة لإعادة الاستخدام""" if self._session is None: import requests self._session = requests.Session() self._session.headers.update(self.headers) return self._session def analyze_text_chunk(self, text: str, chunk_info: str = "") -> dict: """ إرسال مقطع نصي إلى GLM-4.5 للتحليل. Args: text: النص المراد تحليله chunk_info: معلومات عن المقطع (مثل "صفحات 1-5") Returns: dict بالهيكل: {cleaned_text, summary, key_points, rating} """ import requests user_message = f"حلّل النص التالي ({chunk_info}):\n\n{text}" payload = { "model": self.model, "messages": [ {"role": "system", "content": ANALYSIS_SYSTEM_PROMPT}, {"role": "user", "content": user_message}, ], "temperature": 0.3, "max_tokens": 4096, "response_format": {"type": "json_object"}, # إجبار JSON } try: session = self._get_session() response = session.post(self.BASE_URL, json=payload, timeout=120) response.raise_for_status() content = response.json()["choices"][0]["message"].get("content", "") return self._parse_json_response(content) except requests.exceptions.Timeout: logger.error(f"⏰ انتهت مهلة الطلب لـ {chunk_info}") return {"error": "Request timeout"} except requests.exceptions.HTTPError as e: logger.error(f"🌐 خطأ HTTP: {e.response.status_code} — {e.response.text[:200]}") return {"error": f"HTTP {e.response.status_code}"} except Exception as e: logger.error(f"❌ خطأ غير متوقع: {e}") return {"error": str(e)} def analyze_image_chunk(self, images: list[bytes], chunk_info: str = "") -> dict: """ إرسال صور الصفحات إلى GLM-4.5 (وضع Vision) للتحليل. Args: images: قائمة من بيانات الصور (PNG bytes) chunk_info: معلومات عن المقطع Returns: dict بالهيكل: {cleaned_text, summary, key_points, rating} """ import requests # بناء محتوى الرسالة مع الصور content_parts = [ {"type": "text", "text": f"حلّل النص الموجود في الصور التالية ({chunk_info}). " f"استخرج النص أولاً ثم حلّله:"} ] for i, img_bytes in enumerate(images): b64_image = base64.b64encode(img_bytes).decode("utf-8") content_parts.append({ "type": "image_url", "image_url": { "url": f"data:image/png;base64,{b64_image}", } }) payload = { "model": self.model, "messages": [ {"role": "system", "content": ANALYSIS_SYSTEM_PROMPT}, {"role": "user", "content": content_parts}, ], "temperature": 0.3, "max_tokens": 4096, "response_format": {"type": "json_object"}, } try: session = self._get_session() response = session.post(self.BASE_URL, json=payload, timeout=180) response.raise_for_status() content = response.json()["choices"][0]["message"].get("content", "") return self._parse_json_response(content) except Exception as e: logger.error(f"❌ خطأ في وضع Vision: {e}") return {"error": str(e)} def generate_final_summary(self, chunk_summaries: list[str]) -> dict: """ توليد ملخص نهائي شامل من جميع ملخصات الأجزاء. Args: chunk_summaries: قائمة الملخصات الفرعية Returns: dict بالهيكل: {final_summary, overall_rating} """ import requests combined = "\n\n---\n\n".join( [f"### الجزء {i+1}:\n{s}" for i, s in enumerate(chunk_summaries)] ) payload = { "model": self.model, "messages": [ {"role": "system", "content": FINAL_SUMMARY_PROMPT}, {"role": "user", "content": f"اجمع الملخصات التالية في ملخص نهائي:\n\n{combined}"}, ], "temperature": 0.3, "max_tokens": 4096, "response_format": {"type": "json_object"}, } try: session = self._get_session() response = session.post(self.BASE_URL, json=payload, timeout=120) response.raise_for_status() content = response.json()["choices"][0]["message"].get("content", "") return self._parse_json_response(content) except Exception as e: logger.error(f"❌ خطأ في توليد الملخص النهائي: {e}") return {"error": str(e)} @staticmethod def _parse_json_response(content: str) -> dict: """ تحليل استجابة JSON من الـ API مع معالجة الحالات المختلفة. """ content = content.strip() # محاولة 1: تحليل مباشر try: return json.loads(content) except json.JSONDecodeError: pass # محاولة 2: استخراج JSON من بين كتلة كود code_block_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", content, re.DOTALL) if code_block_match: try: return json.loads(code_block_match.group(1).strip()) except json.JSONDecodeError: pass # محاولة 3: البحث عن أول { ... } في النص brace_match = re.search(r"\{.*\}", content, re.DOTALL) if brace_match: try: return json.loads(brace_match.group(0)) except json.JSONDecodeError: pass # فشل — إرجاع النص الخام logger.warning("⚠️ فشل تحليل JSON — إرجاع النص الخام") return { "cleaned_text": content, "summary": "فشل التحليل التلقائي", "key_points": [], "rating": {"score": "N/A", "reason": "لم يتمكن النموذج من إرجاع JSON صالح"}, } # ══════════════════════════════════════════════════════════════════ # 🔄 الخطوة 3: تشغيل الـ Pipeline الكامل # ══════════════════════════════════════════════════════════════════ def run_pipeline( pdf_path: str, api_key: str, model: str = "z-ai/glm-4.5-air", pages_per_chunk: int = 5, mode: str = "text", output_file: Optional[str] = None, ) -> BookAnalysis: """ تشغيل Pipeline تحليل الكتاب بالكامل. Args: pdf_path: مسار ملف الـ PDF api_key: مفتاح OpenRouter API model: اسم النموذج pages_per_chunk: عدد الصفحات في كل مجموعة mode: "text" لاستخراج النص | "vision" لتمرير الصور مباشرة output_file: مسار ملف الإخراج (JSON) — اختياري Returns: BookAnalysis: نتيجة التحليل الكاملة """ start_time = time.time() # ── التحقق من الملف ── if not Path(pdf_path).exists(): logger.error(f"❌ الملف غير موجود: {pdf_path}") raise FileNotFoundError(f"الملف غير موجود: {pdf_path}") if not pdf_path.lower().endswith(".pdf"): logger.error(f"❌ الملف ليس PDF: {pdf_path}") raise ValueError(f"الملف ليس PDF: {pdf_path}") logger.info("=" * 60) logger.info(f"📚 بدء تحليل الكتاب: {Path(pdf_path).name}") logger.info(f" الوضع: {'نصي (Text)' if mode == 'text' else 'بصري (Vision)'}") logger.info(f" النموذج: {model}") logger.info(f" صفحات/مجموعة: {pages_per_chunk}") logger.info("=" * 60) # ── إنشاء عميل GLM ── client = GLMClient(api_key=api_key, model=model) # ── استخراج الصفحات ── if mode == "vision": logger.info("\n🖼️ الوضع البصري: تحويل الصفحات إلى صور...") all_pages = convert_pdf_pages_to_images(pdf_path) else: logger.info("\n📝 الوضع النصي: استخراج النص من الصفحات...") all_pages = extract_text_from_pdf(pdf_path) # تحقق: هل يوجد نص فعلي؟ total_chars = sum(len(p) for p in all_pages) if total_chars < 100: logger.warning(f"⚠️ النص المستخرج قليل جداً ({total_chars} حرف)") logger.info(" 💡 جرّب الوضع البصري: --mode vision") # التبديل التلقائي للوضع البصري logger.info(" 🔄 التبديل التلقائي إلى الوضع البصري...") mode = "vision" all_pages = convert_pdf_pages_to_images(pdf_path) total_pages = len(all_pages) # ── تقسيم إلى مجموعات ── chunks = chunk_pages(all_pages, pages_per_chunk) # ── معالجة كل مجموعة ── results: list[ChunkResult] = [] for idx, chunk in enumerate(chunks): start_page = idx * pages_per_chunk + 1 end_page = min(start_page + pages_per_chunk - 1, total_pages) chunk_info = f"صفحات {start_page}-{end_page}" logger.info(f"\n{'─' * 50}") logger.info(f"🔄 معالجة المجموعة {idx + 1}/{len(chunks)}: {chunk_info}") logger.info(f"{'─' * 50}") # إرسال إلى GLM-4.5 if mode == "vision": api_result = client.analyze_image_chunk(chunk, chunk_info) else: # دمج نصوص الصفحات في نص واحد combined_text = "\n\n--- صفحة جديدة ---\n\n".join(chunk) if not combined_text.strip(): logger.warning(f" ⏭️ تخطي: المجموعة فارغة") results.append(ChunkResult( chunk_index=idx + 1, pages=f"{start_page}-{end_page}", error="Empty chunk — no text extracted", )) continue api_result = client.analyze_text_chunk(combined_text, chunk_info) # بناء النتيجة chunk_result = ChunkResult( chunk_index=idx + 1, pages=f"{start_page}-{end_page}", cleaned_text=api_result.get("cleaned_text", ""), summary=api_result.get("summary", ""), key_points=api_result.get("key_points", []), rating=api_result.get("rating", {"score": "N/A", "reason": ""}), error=api_result.get("error"), ) results.append(chunk_result) # طباعة ملخص سريع if not chunk_result.error: logger.info(f" ✅ ملخص: {chunk_result.summary[:80]}...") logger.info(f" 📊 تقييم: {chunk_result.rating.get('score', 'N/A')}") else: logger.warning(f" ⚠️ خطأ: {chunk_result.error}") # تأخير بسيط لتجنب rate limiting if idx < len(chunks) - 1: logger.info(" ⏳ انتظار 2 ثانية...") time.sleep(2) # ── توليد الملخص النهائي ── successful_summaries = [r.summary for r in results if r.summary and not r.error] final_summary = "" overall_rating = {"score": "N/A", "reason": ""} if len(successful_summaries) > 1: logger.info(f"\n{'═' * 50}") logger.info("📋 توليد الملخص النهائي الشامل...") logger.info(f"{'═' * 50}") final_result = client.generate_final_summary(successful_summaries) final_summary = final_result.get("final_summary", "") overall_rating = final_result.get("overall_rating", {"score": "N/A", "reason": ""}) elif len(successful_summaries) == 1: final_summary = successful_summaries[0] overall_rating = results[0].rating if results else {"score": "N/A", "reason": ""} # ── تجميع النتيجة النهائية ── elapsed = time.time() - start_time analysis = BookAnalysis( book_path=str(Path(pdf_path).resolve()), total_pages=total_pages, total_chunks=len(chunks), processing_time_seconds=round(elapsed, 2), chunks=[asdict(r) for r in results], final_summary=final_summary, overall_rating=overall_rating, ) # ── الحفظ والطباعة ── _print_formatted_output(analysis) if output_file: _save_to_json(analysis, output_file) return analysis # ══════════════════════════════════════════════════════════════════ # 🖨️ الخطوة 4: طباعة وحفظ النتائج # ══════════════════════════════════════════════════════════════════ def _print_formatted_output(analysis: BookAnalysis): """طباعة النتائج بشكل منسق وجميل في الـ Terminal""" print("\n") print("╔" + "═" * 62 + "╗") print("║" + " 📚 نتائج تحليل الكتاب".center(60) + "║") print("╚" + "═" * 62 + "╝") print(f"\n📁 الملف: {Path(analysis.book_path).name}") print(f"📄 عدد الصفحات: {analysis.total_pages}") print(f"✂️ عدد المجموعات: {analysis.total_chunks}") print(f"⏱️ وقت المعالجة: {analysis.processing_time_seconds} ثانية") # ── ملخص كل مجموعة ── for chunk in analysis.chunks: print(f"\n{'─' * 60}") print(f"📖 المجموعة {chunk['chunk_index']} (صفحات {chunk['pages']})") print(f"{'─' * 60}") if chunk.get("error"): print(f" ⚠️ خطأ: {chunk['error']}") continue if chunk.get("summary"): print(f"\n 📝 الملخص:") _print_wrapped(chunk["summary"], indent=6) if chunk.get("key_points"): print(f"\n 🔑 أهم النقاط:") for i, point in enumerate(chunk["key_points"], 1): print(f" {i}. {point}") if chunk.get("rating"): rating = chunk["rating"] print(f"\n 📊 التقييم: {rating.get('score', 'N/A')}") if rating.get("reason"): print(f" السبب: {rating['reason']}") # ── الملخص النهائي ── if analysis.final_summary: print(f"\n{'═' * 60}") print("📋 الملخص النهائي الشامل:") print(f"{'═' * 60}") _print_wrapped(analysis.final_summary, indent=3) if analysis.overall_rating and analysis.overall_rating.get("score") != "N/A": print(f"\n🏆 التقييم النهائي: {analysis.overall_rating.get('score', 'N/A')}") if analysis.overall_rating.get("reason"): print(f" السبب: {analysis.overall_rating['reason']}") print(f"\n{'═' * 60}\n") def _print_wrapped(text: str, indent: int = 0, width: int = 76): """طباعة نص طويل مع التفاف الأسطر""" prefix = " " * indent words = text.split() line = prefix for word in words: if len(line) + len(word) + 1 > width: print(line) line = prefix + word else: line = line + " " + word if line.strip() else prefix + word if line.strip(): print(line) def _save_to_json(analysis: BookAnalysis, output_path: str): """حفظ النتائج في ملف JSON""" output = asdict(analysis) with open(output_path, "w", encoding="utf-8") as f: json.dump(output, f, ensure_ascii=False, indent=2) file_size = Path(output_path).stat().st_size / 1024 logger.info(f"💾 تم حفظ النتائج في: {output_path} ({file_size:.1f} KB)") # ══════════════════════════════════════════════════════════════════ # 🚀 نقطة الدخول (Entry Point) # ══════════════════════════════════════════════════════════════════ def main(): parser = argparse.ArgumentParser( description="📚 Book Analysis Pipeline — Phoenix AI", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" أمثلة: python book_pipeline.py "كتابي.pdf" python book_pipeline.py "كتابي.pdf" --output results.json python book_pipeline.py "كتابي.pdf" --mode vision --pages-per-chunk 3 python book_pipeline.py "كتابي.pdf" --api-key sk-or-v1-xxxxx """, ) parser.add_argument("pdf_path", help="مسار ملف الـ PDF") parser.add_argument("--output", "-o", help="مسار ملف الإخراج (JSON)", default=None) parser.add_argument( "--pages-per-chunk", "-c", type=int, default=5, help="عدد الصفحات في كل مجموعة (افتراضي: 5)", ) parser.add_argument( "--mode", "-m", choices=["text", "vision"], default="text", help="وضع المعالجة: text (استخراج النص) أو vision (تمرير الصور)", ) parser.add_argument( "--model", default="z-ai/glm-4.5-air", help="اسم النموذج على OpenRouter (افتراضي: glm-4.5-air)", ) # ════════════════════════════════════════════════════════════ # ⬇️ يمكنك وضع مفتاح API هنا بدلاً من ملف .env # ════════════════════════════════════════════════════════════ parser.add_argument( "--api-key", default=os.getenv("OPENROUTER_API_KEY", ""), help="مفتاح OpenRouter API (أو ضعه في .env كـ OPENROUTER_API_KEY)", ) args = parser.parse_args() try: # تشغيل الـ Pipeline run_pipeline( pdf_path=args.pdf_path, api_key=args.api_key, model=args.model, pages_per_chunk=args.pages_per_chunk, mode=args.mode, output_file=args.output, ) except Exception as e: logger.error(f"❌ حدث خطأ أثناء تشغيل Pipeline: {e}") sys.exit(1) if __name__ == "__main__": main()