Spaces:
Sleeping
Sleeping
| import os | |
| import sys | |
| from fastapi import FastAPI, Depends, HTTPException, UploadFile, File | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from sqlalchemy.orm import Session | |
| from typing import List | |
| from dotenv import load_dotenv | |
| # Force UTF-8 encoding for standard output to prevent Uvicorn from crashing when logging Arabic URLs on Windows | |
| if sys.platform == 'win32' and sys.stdout: | |
| sys.stdout.reconfigure(encoding='utf-8') | |
| from app.db.database import engine, get_db | |
| from app.models import models | |
| from app.schemas import schemas | |
| from app.services.scraper import scrape_article_url | |
| from app.security import get_password_hash, verify_password, create_access_token, get_current_user, ACCESS_TOKEN_EXPIRE_MINUTES | |
| from datetime import timedelta | |
| from fastapi.security import OAuth2PasswordRequestForm | |
| # Load environment variables from .env file | |
| load_dotenv() | |
| # Initialize database tables | |
| models.Base.metadata.create_all(bind=engine) | |
| app = FastAPI(title="Smart Reader API", version="1.0.0") | |
| # --- CORS Middleware Configuration --- | |
| cors_origins_str = os.environ.get("CORS_ORIGINS", "") | |
| if cors_origins_str: | |
| allow_origins = [origin.strip() for origin in cors_origins_str.split(",")] | |
| else: | |
| allow_origins = ["*"] # Fallback for local development if not set | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=allow_origins, | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # --- Sentiment Analysis Helper --- | |
| def analyze_sentiment(text: str) -> str: | |
| """ | |
| Analyzes the sentiment of a comment text. | |
| Works for both Arabic and English using a keyword-based lexicon approach. | |
| """ | |
| t = text.lower() | |
| positive_keywords = [ | |
| "جميل", "رائع", "ممتاز", "مفيد", "شكرا", "تحفة", "حب", "جيد", "حلو", | |
| "عظيم", "أعجبني", "قوي", "سهل", "واضح", | |
| "nice", "good", "great", "awesome", "love", "useful", "thanks", "like", "easy" | |
| ] | |
| negative_keywords = [ | |
| "سيء", "صعب", "ملل", "خطأ", "فشل", "ضعيف", "لا يعجبني", "حزين", "أسف", | |
| "وحش", "ركيك", "ممل", "معقد", "ناقص", | |
| "bad", "worst", "boring", "error", "fail", "sad", "dislike", "hate", "hard", "difficult" | |
| ] | |
| pos_count = sum(1 for word in positive_keywords if word in t) | |
| neg_count = sum(1 for word in negative_keywords if word in t) | |
| if pos_count > neg_count: | |
| return "POSITIVE" | |
| elif neg_count > pos_count: | |
| return "NEGATIVE" | |
| return "NEUTRAL" | |
| # --- AI Summarization Helper --- | |
| def generate_ai_summary(content: str) -> str: | |
| """ | |
| Generates a summary of the article using Groq API. | |
| Falls back to a clean local extraction summary if no Groq API Key is set. | |
| """ | |
| api_key = os.environ.get("GROQ_API_KEY") | |
| if api_key and api_key.strip(): | |
| try: | |
| from groq import Groq | |
| client = Groq(api_key=api_key) | |
| response = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=[ | |
| {"role": "system", "content": "You are a professional summarizer. You MUST output ONLY the raw summary text. No intro, no outro, no conversational filler. Summarize the text in the exact same language as the input text."}, | |
| {"role": "user", "content": f"Summarize this text in 2-3 sentences. Start immediately with the first word of the summary:\n\n{content[:4000]}"} | |
| ], | |
| max_tokens=300, | |
| ) | |
| summary = response.choices[0].message.content.strip() | |
| # Programmatically strip common conversational fillers | |
| fillers = [ | |
| "Here is a summary of the text", | |
| "Here is a summary", | |
| "Here is the summary", | |
| "Sure, here is a summary", | |
| "The text can be summarized as follows:", | |
| "This text is about", | |
| "إليك ملخص", | |
| "فيما يلي ملخص" | |
| ] | |
| for filler in fillers: | |
| if summary.lower().startswith(filler.lower()): | |
| # Find the end of the first line or colon and slice it | |
| idx = summary.find("\n\n") | |
| if idx != -1: | |
| summary = summary[idx+2:].strip() | |
| else: | |
| idx = summary.find(":") | |
| if idx != -1 and idx < 100: | |
| summary = summary[idx+1:].strip() | |
| return summary.strip('"\'- \n') | |
| except Exception as e: | |
| print(f"Error calling Groq API: {e}") | |
| # Fallback / Local summarization (First 2 full sentences) | |
| sentences = content.replace("\n", " ").split(".") | |
| fallback_summary = ". ".join([s.strip() for s in sentences[:2] if s.strip()]) | |
| if fallback_summary: | |
| return fallback_summary + "." | |
| return content[:150] + "..." | |
| # --- Auth Endpoints --- | |
| def register(user_in: schemas.UserCreate, db: Session = Depends(get_db)): | |
| user = db.query(models.User).filter(models.User.email == user_in.email).first() | |
| if user: | |
| raise HTTPException(status_code=400, detail="البريد الإلكتروني مسجل مسبقاً") | |
| hashed_password = get_password_hash(user_in.password) | |
| new_user = models.User(name=user_in.name, email=user_in.email, hashed_password=hashed_password) | |
| db.add(new_user) | |
| db.commit() | |
| db.refresh(new_user) | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| access_token = create_access_token( | |
| data={"sub": new_user.email}, expires_delta=access_token_expires | |
| ) | |
| return {"access_token": access_token, "token_type": "bearer", "user": new_user} | |
| def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): | |
| user = db.query(models.User).filter(models.User.email == form_data.username).first() | |
| if not user or not verify_password(form_data.password, user.hashed_password): | |
| raise HTTPException( | |
| status_code=401, | |
| detail="البريد الإلكتروني أو كلمة المرور غير صحيحة", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| access_token = create_access_token( | |
| data={"sub": user.email}, expires_delta=access_token_expires | |
| ) | |
| return {"access_token": access_token, "token_type": "bearer", "user": user} | |
| # --- Endpoints --- | |
| def get_articles(db: Session = Depends(get_db)): | |
| articles = db.query(models.Article).all() | |
| return articles | |
| def get_article_summary(article_id: int, db: Session = Depends(get_db)): | |
| article = db.query(models.Article).filter(models.Article.id == article_id).first() | |
| if not article: | |
| raise HTTPException(status_code=404, detail="المقال غير موجود") | |
| # Generate live summary | |
| summary = generate_ai_summary(article.content) | |
| return summary | |
| def create_comment(comment_in: schemas.CommentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)): | |
| article_id = None | |
| if comment_in.article and hasattr(comment_in.article, 'id'): | |
| article_id = comment_in.article.id | |
| elif isinstance(comment_in.article, dict) and 'id' in comment_in.article: | |
| article_id = comment_in.article['id'] | |
| if not article_id: | |
| raise HTTPException(status_code=400, detail="يجب تحديد المقال المرتبط بالتعليق") | |
| article = db.query(models.Article).filter(models.Article.id == article_id).first() | |
| if not article: | |
| raise HTTPException(status_code=404, detail="المقال غير موجود") | |
| sentiment = analyze_sentiment(comment_in.text) | |
| db_comment = models.Comment( | |
| text=comment_in.text, | |
| sentiment=sentiment, | |
| user_id=current_user.id, | |
| article_id=article.id | |
| ) | |
| db.add(db_comment) | |
| db.commit() | |
| db.refresh(db_comment) | |
| return db_comment | |
| def rate_comment(comment_id: int, rating_in: schemas.CommentRatingCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)): | |
| comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() | |
| if not comment: | |
| raise HTTPException(status_code=404, detail="التعليق غير موجود") | |
| if rating_in.vote not in [1, -1]: | |
| raise HTTPException(status_code=400, detail="تقييم غير صالح. يجب أن يكون 1 أو -1") | |
| # Check if user already rated this comment | |
| existing_rating = db.query(models.CommentRating).filter( | |
| models.CommentRating.comment_id == comment_id, | |
| models.CommentRating.user_id == current_user.id | |
| ).first() | |
| if existing_rating: | |
| if existing_rating.vote == rating_in.vote: | |
| # User wants to remove their vote | |
| db.delete(existing_rating) | |
| else: | |
| # User wants to change their vote | |
| existing_rating.vote = rating_in.vote | |
| else: | |
| # Create new rating | |
| new_rating = models.CommentRating( | |
| vote=rating_in.vote, | |
| user_id=current_user.id, | |
| comment_id=comment_id | |
| ) | |
| db.add(new_rating) | |
| db.commit() | |
| db.refresh(comment) | |
| return comment | |
| # --- 🚀 Upgraded Scraping Endpoint --- | |
| def scrape_and_add_article(req: schemas.ScrapeRequest, db: Session = Depends(get_db)): | |
| scraped_data = scrape_article_url(req.url) | |
| # Auto-generate next ID | |
| max_id_article = db.query(models.Article).order_by(models.Article.id.desc()).first() | |
| next_id = (max_id_article.id + 1) if max_id_article else 1 | |
| db_article = models.Article( | |
| id=next_id, | |
| title=scraped_data["title"], | |
| content=scraped_data["content"], | |
| category=scraped_data["category"], | |
| author=scraped_data["author"], | |
| image=scraped_data["image"], | |
| summary="" # Generate on demand | |
| ) | |
| db.add(db_article) | |
| db.commit() | |
| db.refresh(db_article) | |
| return db_article | |
| # --- 📁 File Upload Endpoint --- | |
| import io | |
| import docx | |
| from PyPDF2 import PdfReader | |
| async def upload_document(file: UploadFile = File(...), db: Session = Depends(get_db)): | |
| if not file.filename: | |
| raise HTTPException(status_code=400, detail="الملف غير صالح") | |
| ext = file.filename.split('.')[-1].lower() | |
| content_text = "" | |
| title = file.filename.rsplit('.', 1)[0] | |
| try: | |
| file_bytes = await file.read() | |
| if ext == "txt": | |
| content_text = file_bytes.decode('utf-8', errors='ignore') | |
| elif ext == "pdf": | |
| reader = PdfReader(io.BytesIO(file_bytes)) | |
| for page in reader.pages: | |
| text = page.extract_text() | |
| if text: | |
| content_text += text + "\n" | |
| elif ext == "docx": | |
| doc = docx.Document(io.BytesIO(file_bytes)) | |
| for para in doc.paragraphs: | |
| content_text += para.text + "\n" | |
| else: | |
| raise HTTPException(status_code=400, detail="نوع الملف غير مدعوم. يرجى رفع ملفات (pdf, docx, txt)") | |
| if not content_text.strip(): | |
| raise HTTPException(status_code=400, detail="لم نتمكن من قراءة أي نص من الملف") | |
| # Add to database as article | |
| max_id_article = db.query(models.Article).order_by(models.Article.id.desc()).first() | |
| next_id = (max_id_article.id + 1) if max_id_article else 1 | |
| db_article = models.Article( | |
| id=next_id, | |
| title=title, | |
| content=content_text.strip(), | |
| category="مستند مرفوع", | |
| author="أنت", | |
| image=None, | |
| summary="" | |
| ) | |
| db.add(db_article) | |
| db.commit() | |
| db.refresh(db_article) | |
| return db_article | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"حدث خطأ أثناء معالجة الملف: {str(e)}") | |
| # --- 🚀 Upgraded Chatbot (RAG) Endpoint --- | |
| def chat_with_article(article_id: int, chat_req: schemas.ChatRequest, db: Session = Depends(get_db)): | |
| article = db.query(models.Article).filter(models.Article.id == article_id).first() | |
| if not article: | |
| raise HTTPException(status_code=404, detail="المقال غير موجود") | |
| api_key = os.environ.get("GROQ_API_KEY") | |
| if not api_key or not api_key.strip(): | |
| # Fallback offline replies | |
| user_msg = chat_req.message.lower() | |
| if any(greeting in user_msg for greeting in ["مرحباً", "hello", "hi", "سلام", "هلا"]): | |
| return {"reply": "أهلاً بك! أنا مساعدك الذكي لقراءة وتلخيص هذا المقال. كيف يمكنني مساعدتك اليوم؟"} | |
| if any(kw in user_msg for kw in ["لخص", "ملخص", "summary", "summarize"]): | |
| summary_text = article.summary if article.summary else generate_ai_summary(article.content) | |
| return {"reply": f"إليك ملخص سريع للمقال:\n{summary_text}"} | |
| return {"reply": f"أهلاً بك! لم يتم ضبط مفتاح Groq API Key في ملف `.env` بعد. يمكنك الحصول عليه مجاناً من https://console.groq.com"} | |
| try: | |
| from groq import Groq | |
| client = Groq(api_key=api_key) | |
| system_prompt = f"""أنت مساعد قراءة ذكي وتفاعلي لموقع "Smart Reader". | |
| وظيفتك الإجابة عن أي أسئلة يطرحها القارئ حول المقال التالي بدقة بالغة وبنفس لغة سؤال القارئ (العربية أو الإنجليزية). | |
| تجنب تأليف معلومات ليست في المقال، وإذا سألك عن موضوع عام غير موجود بالمقال وضح له ذلك ثم أجب باختصار وبشكل مفيد. | |
| محتوى المقال كمرجع لك: | |
| العنوان: {article.title} | |
| الكاتب: {article.author} | |
| التصنيف: {article.category} | |
| المحتوى: | |
| {article.content[:4000]}""" | |
| # Build messages from history | |
| messages = [{"role": "system", "content": system_prompt}] | |
| if chat_req.history: | |
| for h in chat_req.history: | |
| role = "user" if h.role == "user" else "assistant" | |
| messages.append({"role": role, "content": h.content}) | |
| messages.append({"role": "user", "content": chat_req.message}) | |
| response = client.chat.completions.create( | |
| model="llama-3.3-70b-versatile", | |
| messages=messages, | |
| max_tokens=1024, | |
| ) | |
| return {"reply": response.choices[0].message.content.strip()} | |
| except Exception as e: | |
| print(f"Groq Chat error: {e}") | |
| return {"reply": f"حدث خطأ أثناء الاتصال بالذكاء الاصطناعي: {str(e)}"} | |
| # --- 🔍 Web Search Endpoint --- | |
| def web_search(q: str, max_results: int = 8): | |
| """ | |
| Searches Wikipedia (Arabic or English depending on query) and returns article results. | |
| """ | |
| if not q or not q.strip(): | |
| raise HTTPException(status_code=400, detail="يجب إدخال كلمة بحث") | |
| try: | |
| import urllib.request | |
| import urllib.parse | |
| import json | |
| import re | |
| # Detect if query has Arabic characters | |
| is_arabic = bool(re.search(r'[\u0600-\u06FF]', q)) | |
| lang = "ar" if is_arabic else "en" | |
| # Wikipedia Search API | |
| url = f"https://{lang}.wikipedia.org/w/api.php?action=query&list=search&srsearch={urllib.parse.quote(q)}&utf8=&format=json&srlimit={max_results}" | |
| headers = { | |
| 'User-Agent': 'SmartReaderBot/1.0 (https://github.com/mohamedragab478/smart-reader-backend; bot@example.com)' | |
| } | |
| req = urllib.request.Request(url, headers=headers) | |
| try: | |
| with urllib.request.urlopen(req, timeout=10) as response: | |
| data = json.loads(response.read().decode('utf-8')) | |
| except urllib.error.HTTPError as e: | |
| if e.code == 429: | |
| raise HTTPException(status_code=429, detail="عذراً، ويكيبيديا تمنع كثرة الطلبات حالياً. يرجى المحاولة بعد قليل.") | |
| raise e | |
| results = [] | |
| for item in data.get('query', {}).get('search', []): | |
| title = item.get('title', '') | |
| # Clean HTML tags from snippet | |
| snippet_html = item.get('snippet', '') | |
| snippet = re.sub(r'<[^>]+>', '', snippet_html) | |
| # Construct Wikipedia URL | |
| article_url = f"https://{lang}.wikipedia.org/wiki/{urllib.parse.quote(title.replace(' ', '_'))}" | |
| results.append(schemas.SearchResult( | |
| title=title, | |
| url=article_url, | |
| snippet=snippet, | |
| image=None, | |
| source="ويكيبيديا" if is_arabic else "Wikipedia" | |
| )) | |
| return results | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| raise HTTPException(status_code=500, detail=f"حدث خطأ أثناء البحث: {str(e)}") | |