Mohamed478 commited on
Commit
2706084
·
0 Parent(s):

Initial backend commit

Browse files
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ GROQ_API_KEY=your_groq_api_key_here
2
+ CORS_ORIGINS=https://YOUR_PROJECT.vercel.app,http://localhost:5173
3
+ DATABASE_URL=sqlite:///./smart_reader.db
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ *.db
7
+ *.sqlite
8
+ .pytest_cache/
9
+ error.log
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Install uv
7
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
8
+
9
+ # Copy requirements first to leverage Docker cache
10
+ COPY requirements.txt .
11
+
12
+ # Install dependencies using uv
13
+ RUN uv pip install --system --no-cache -r requirements.txt
14
+
15
+ # Copy the rest of the application code
16
+ COPY . .
17
+
18
+ # Expose port 7860 for HuggingFace Spaces
19
+ EXPOSE 7860
20
+
21
+ # Run Uvicorn on 0.0.0.0:7860
22
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Smart Reader Backend
2
+
3
+ This is the FastAPI backend for the Smart Reader application, designed to be deployed on HuggingFace Spaces.
4
+
5
+ ## Requirements
6
+ - Python 3.11+
7
+ - Groq API Key
8
+
9
+ ## Environment Variables
10
+ Create a `.env` file in the root directory and add the following variables:
11
+ ```
12
+ GROQ_API_KEY=your_groq_api_key_here
13
+ CORS_ORIGINS=https://YOUR_PROJECT.vercel.app,http://localhost:5173
14
+ DATABASE_URL=sqlite:///./smart_reader.db
15
+ ```
16
+
17
+ ## Local Development
18
+ 1. Install dependencies:
19
+ ```bash
20
+ pip install -r requirements.txt
21
+ ```
22
+ 2. Run the application:
23
+ ```bash
24
+ uvicorn main:app --reload --port 8080
25
+ ```
26
+
27
+ ## Docker
28
+ Build and run the Docker container locally:
29
+ ```bash
30
+ docker build -t smart-reader-backend .
31
+ docker run -p 7860:7860 --env-file .env smart-reader-backend
32
+ ```
33
+
34
+ ## HuggingFace Deployment
35
+ This repository is configured for HuggingFace Spaces using Docker.
36
+ - The `Dockerfile` exposes port `7860`.
37
+ - Set your `GROQ_API_KEY` and `CORS_ORIGINS` in the HuggingFace Spaces secrets configuration.
app/db/database.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker
4
+
5
+ SQLALCHEMY_DATABASE_URL = "sqlite:///./smart_reader.db"
6
+
7
+ engine = create_engine(
8
+ SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
9
+ )
10
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
11
+
12
+ Base = declarative_base()
13
+
14
+ def get_db():
15
+ db = SessionLocal()
16
+ try:
17
+ yield db
18
+ finally:
19
+ db.close()
app/main.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from sqlalchemy.orm import Session
6
+ from typing import List
7
+ from dotenv import load_dotenv
8
+
9
+ # Force UTF-8 encoding for standard output to prevent Uvicorn from crashing when logging Arabic URLs on Windows
10
+ if sys.platform == 'win32' and sys.stdout:
11
+ sys.stdout.reconfigure(encoding='utf-8')
12
+
13
+ from app.db.database import engine, get_db
14
+ from app.models import models
15
+ from app.schemas import schemas
16
+ from app.services.scraper import scrape_article_url
17
+
18
+ # Load environment variables from .env file
19
+ load_dotenv()
20
+
21
+ # Initialize database tables
22
+ models.Base.metadata.create_all(bind=engine)
23
+
24
+ app = FastAPI(title="Smart Reader API", version="1.0.0")
25
+
26
+ # --- CORS Middleware Configuration ---
27
+ cors_origins_str = os.environ.get("CORS_ORIGINS", "")
28
+ if cors_origins_str:
29
+ allow_origins = [origin.strip() for origin in cors_origins_str.split(",")]
30
+ else:
31
+ allow_origins = ["*"] # Fallback for local development if not set
32
+
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=allow_origins,
36
+ allow_credentials=True,
37
+ allow_methods=["*"],
38
+ allow_headers=["*"],
39
+ )
40
+
41
+ # --- Sentiment Analysis Helper ---
42
+ def analyze_sentiment(text: str) -> str:
43
+ """
44
+ Analyzes the sentiment of a comment text.
45
+ Works for both Arabic and English using a keyword-based lexicon approach.
46
+ """
47
+ t = text.lower()
48
+ positive_keywords = [
49
+ "جميل", "رائع", "ممتاز", "مفيد", "شكرا", "تحفة", "حب", "جيد", "حلو",
50
+ "عظيم", "أعجبني", "قوي", "سهل", "واضح",
51
+ "nice", "good", "great", "awesome", "love", "useful", "thanks", "like", "easy"
52
+ ]
53
+ negative_keywords = [
54
+ "سيء", "صعب", "ملل", "خطأ", "فشل", "ضعيف", "لا يعجبني", "حزين", "أسف",
55
+ "وحش", "ركيك", "ممل", "معقد", "ناقص",
56
+ "bad", "worst", "boring", "error", "fail", "sad", "dislike", "hate", "hard", "difficult"
57
+ ]
58
+
59
+ pos_count = sum(1 for word in positive_keywords if word in t)
60
+ neg_count = sum(1 for word in negative_keywords if word in t)
61
+
62
+ if pos_count > neg_count:
63
+ return "POSITIVE"
64
+ elif neg_count > pos_count:
65
+ return "NEGATIVE"
66
+ return "NEUTRAL"
67
+
68
+ # --- AI Summarization Helper ---
69
+ def generate_ai_summary(content: str) -> str:
70
+ """
71
+ Generates a summary of the article using Groq API.
72
+ Falls back to a clean local extraction summary if no Groq API Key is set.
73
+ """
74
+ api_key = os.environ.get("GROQ_API_KEY")
75
+ if api_key and api_key.strip():
76
+ try:
77
+ from groq import Groq
78
+ client = Groq(api_key=api_key)
79
+ response = client.chat.completions.create(
80
+ model="llama-3.3-70b-versatile",
81
+ messages=[
82
+ {"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."},
83
+ {"role": "user", "content": f"Summarize this text in 2-3 sentences. Start immediately with the first word of the summary:\n\n{content[:4000]}"}
84
+ ],
85
+ max_tokens=300,
86
+ )
87
+ summary = response.choices[0].message.content.strip()
88
+
89
+ # Programmatically strip common conversational fillers
90
+ fillers = [
91
+ "Here is a summary of the text",
92
+ "Here is a summary",
93
+ "Here is the summary",
94
+ "Sure, here is a summary",
95
+ "The text can be summarized as follows:",
96
+ "This text is about",
97
+ "إليك ملخص",
98
+ "فيما يلي ملخص"
99
+ ]
100
+ for filler in fillers:
101
+ if summary.lower().startswith(filler.lower()):
102
+ # Find the end of the first line or colon and slice it
103
+ idx = summary.find("\n\n")
104
+ if idx != -1:
105
+ summary = summary[idx+2:].strip()
106
+ else:
107
+ idx = summary.find(":")
108
+ if idx != -1 and idx < 100:
109
+ summary = summary[idx+1:].strip()
110
+ return summary.strip('"\'- \n')
111
+ except Exception as e:
112
+ print(f"Error calling Groq API: {e}")
113
+
114
+ # Fallback / Local summarization (First 2 full sentences)
115
+ sentences = content.replace("\n", " ").split(".")
116
+ fallback_summary = ". ".join([s.strip() for s in sentences[:2] if s.strip()])
117
+ if fallback_summary:
118
+ return fallback_summary + "."
119
+ return content[:150] + "..."
120
+
121
+ # --- Endpoints ---
122
+
123
+ @app.get("/api/v1/articles", response_model=List[schemas.ArticleResponse])
124
+ def get_articles(db: Session = Depends(get_db)):
125
+ articles = db.query(models.Article).all()
126
+ return articles
127
+
128
+ @app.get("/api/v1/ai/articles/{article_id}/summary")
129
+ def get_article_summary(article_id: int, db: Session = Depends(get_db)):
130
+ article = db.query(models.Article).filter(models.Article.id == article_id).first()
131
+ if not article:
132
+ raise HTTPException(status_code=404, detail="المقال غير موجود")
133
+
134
+ # Generate live summary
135
+ summary = generate_ai_summary(article.content)
136
+ return summary
137
+
138
+ @app.post("/api/v1/comments", response_model=schemas.CommentResponse)
139
+ def create_comment(comment_in: schemas.CommentCreate, db: Session = Depends(get_db)):
140
+ user_id = comment_in.user.id if comment_in.user else 1
141
+ user = db.query(models.User).filter(models.User.id == user_id).first()
142
+ if not user:
143
+ user = models.User(id=user_id, name="مستخدم جديد")
144
+ db.add(user)
145
+ db.commit()
146
+ db.refresh(user)
147
+
148
+ article_id = None
149
+ if comment_in.article and hasattr(comment_in.article, 'id'):
150
+ article_id = comment_in.article.id
151
+
152
+ if not article_id:
153
+ raise HTTPException(status_code=400, detail="يجب تحديد المقال المرتبط بالتعليق")
154
+
155
+ article = db.query(models.Article).filter(models.Article.id == article_id).first()
156
+ if not article:
157
+ raise HTTPException(status_code=404, detail="المقال غير موجود")
158
+
159
+ sentiment = analyze_sentiment(comment_in.text)
160
+
161
+ db_comment = models.Comment(
162
+ text=comment_in.text,
163
+ sentiment=sentiment,
164
+ user_id=user.id,
165
+ article_id=article.id
166
+ )
167
+ db.add(db_comment)
168
+ db.commit()
169
+ db.refresh(db_comment)
170
+
171
+ return db_comment
172
+
173
+ # --- 🚀 Upgraded Scraping Endpoint ---
174
+ @app.post("/api/v1/articles/scrape", response_model=schemas.ArticleResponse)
175
+ def scrape_and_add_article(req: schemas.ScrapeRequest, db: Session = Depends(get_db)):
176
+ scraped_data = scrape_article_url(req.url)
177
+
178
+ # Auto-generate next ID
179
+ max_id_article = db.query(models.Article).order_by(models.Article.id.desc()).first()
180
+ next_id = (max_id_article.id + 1) if max_id_article else 1
181
+
182
+ db_article = models.Article(
183
+ id=next_id,
184
+ title=scraped_data["title"],
185
+ content=scraped_data["content"],
186
+ category=scraped_data["category"],
187
+ author=scraped_data["author"],
188
+ image=scraped_data["image"],
189
+ summary="" # Generate on demand
190
+ )
191
+
192
+ db.add(db_article)
193
+ db.commit()
194
+ db.refresh(db_article)
195
+
196
+ return db_article
197
+
198
+ # --- 📁 File Upload Endpoint ---
199
+ import io
200
+ import docx
201
+ from PyPDF2 import PdfReader
202
+
203
+ @app.post("/api/v1/articles/upload", response_model=schemas.ArticleResponse)
204
+ async def upload_document(file: UploadFile = File(...), db: Session = Depends(get_db)):
205
+ if not file.filename:
206
+ raise HTTPException(status_code=400, detail="الملف غير صالح")
207
+
208
+ ext = file.filename.split('.')[-1].lower()
209
+ content_text = ""
210
+ title = file.filename.rsplit('.', 1)[0]
211
+
212
+ try:
213
+ file_bytes = await file.read()
214
+
215
+ if ext == "txt":
216
+ content_text = file_bytes.decode('utf-8', errors='ignore')
217
+ elif ext == "pdf":
218
+ reader = PdfReader(io.BytesIO(file_bytes))
219
+ for page in reader.pages:
220
+ text = page.extract_text()
221
+ if text:
222
+ content_text += text + "\n"
223
+ elif ext == "docx":
224
+ doc = docx.Document(io.BytesIO(file_bytes))
225
+ for para in doc.paragraphs:
226
+ content_text += para.text + "\n"
227
+ else:
228
+ raise HTTPException(status_code=400, detail="نوع الملف غير مدعوم. يرجى رفع ملفات (pdf, docx, txt)")
229
+
230
+ if not content_text.strip():
231
+ raise HTTPException(status_code=400, detail="لم نتمكن من قراءة أي نص من الملف")
232
+
233
+ # Add to database as article
234
+ max_id_article = db.query(models.Article).order_by(models.Article.id.desc()).first()
235
+ next_id = (max_id_article.id + 1) if max_id_article else 1
236
+
237
+ db_article = models.Article(
238
+ id=next_id,
239
+ title=title,
240
+ content=content_text.strip(),
241
+ category="مستند مرفوع",
242
+ author="أنت",
243
+ image=None,
244
+ summary=""
245
+ )
246
+ db.add(db_article)
247
+ db.commit()
248
+ db.refresh(db_article)
249
+ return db_article
250
+
251
+ except HTTPException:
252
+ raise
253
+ except Exception as e:
254
+ raise HTTPException(status_code=500, detail=f"حدث خطأ أثناء معالجة الملف: {str(e)}")
255
+
256
+ # --- 🚀 Upgraded Chatbot (RAG) Endpoint ---
257
+ @app.post("/api/v1/ai/articles/{article_id}/chat")
258
+ def chat_with_article(article_id: int, chat_req: schemas.ChatRequest, db: Session = Depends(get_db)):
259
+ article = db.query(models.Article).filter(models.Article.id == article_id).first()
260
+ if not article:
261
+ raise HTTPException(status_code=404, detail="المقال غير موجود")
262
+
263
+ api_key = os.environ.get("GROQ_API_KEY")
264
+ if not api_key or not api_key.strip():
265
+ # Fallback offline replies
266
+ user_msg = chat_req.message.lower()
267
+ if any(greeting in user_msg for greeting in ["مرحباً", "hello", "hi", "سلام", "هلا"]):
268
+ return {"reply": "أهلاً بك! أنا مساعدك الذكي لقراءة وتلخيص هذا المقال. كيف يمكنني مساعدتك اليوم؟"}
269
+
270
+ if any(kw in user_msg for kw in ["لخص", "ملخص", "summary", "summarize"]):
271
+ summary_text = article.summary if article.summary else generate_ai_summary(article.content)
272
+ return {"reply": f"إليك ملخص سريع للمقال:\n{summary_text}"}
273
+
274
+ return {"reply": f"أهلاً بك! لم يتم ضبط مفتاح Groq API Key في ملف `.env` بعد. يمكنك الحصول عليه مجاناً من https://console.groq.com"}
275
+
276
+ try:
277
+ from groq import Groq
278
+ client = Groq(api_key=api_key)
279
+
280
+ system_prompt = f"""أنت مساعد قراءة ذكي وتفاعلي لموقع "Smart Reader".
281
+ وظيفتك الإجابة عن أي أسئلة يطرحها القارئ حول المقال التالي بدقة بالغة وبنفس لغة سؤال القارئ (العربية أو الإنجليزية).
282
+ تجنب تأليف معلومات ليست في المقال، وإذا سألك عن موضوع عام غير موجود بالمقال وضح له ذلك ثم أجب باختصار وبشكل مفيد.
283
+
284
+ محتوى المقال كمرجع لك:
285
+ العنوان: {article.title}
286
+ الكاتب: {article.author}
287
+ التصنيف: {article.category}
288
+ المحتوى:
289
+ {article.content[:4000]}"""
290
+
291
+ # Build messages from history
292
+ messages = [{"role": "system", "content": system_prompt}]
293
+ if chat_req.history:
294
+ for h in chat_req.history:
295
+ role = "user" if h.role == "user" else "assistant"
296
+ messages.append({"role": role, "content": h.content})
297
+ messages.append({"role": "user", "content": chat_req.message})
298
+
299
+ response = client.chat.completions.create(
300
+ model="llama-3.3-70b-versatile",
301
+ messages=messages,
302
+ max_tokens=1024,
303
+ )
304
+ return {"reply": response.choices[0].message.content.strip()}
305
+ except Exception as e:
306
+ print(f"Groq Chat error: {e}")
307
+ return {"reply": f"حدث خطأ أثناء الاتصال بالذكاء الاصطناعي: {str(e)}"}
308
+
309
+ # --- 🔍 Web Search Endpoint ---
310
+ @app.get("/api/v1/search", response_model=List[schemas.SearchResult])
311
+ def web_search(q: str, max_results: int = 8):
312
+ """
313
+ Searches Wikipedia (Arabic or English depending on query) and returns article results.
314
+ """
315
+ if not q or not q.strip():
316
+ raise HTTPException(status_code=400, detail="يجب إدخال كلمة بحث")
317
+ try:
318
+ import urllib.request
319
+ import urllib.parse
320
+ import json
321
+ import re
322
+
323
+ # Detect if query has Arabic characters
324
+ is_arabic = bool(re.search(r'[\u0600-\u06FF]', q))
325
+ lang = "ar" if is_arabic else "en"
326
+
327
+ # Wikipedia Search API
328
+ url = f"https://{lang}.wikipedia.org/w/api.php?action=query&list=search&srsearch={urllib.parse.quote(q)}&utf8=&format=json&srlimit={max_results}"
329
+ req = urllib.request.Request(url, headers={'User-Agent': 'SmartReader/1.0'})
330
+
331
+ with urllib.request.urlopen(req, timeout=10) as response:
332
+ data = json.loads(response.read().decode('utf-8'))
333
+
334
+ results = []
335
+ for item in data.get('query', {}).get('search', []):
336
+ title = item.get('title', '')
337
+ # Clean HTML tags from snippet
338
+ snippet_html = item.get('snippet', '')
339
+ snippet = re.sub(r'<[^>]+>', '', snippet_html)
340
+
341
+ # Construct Wikipedia URL
342
+ article_url = f"https://{lang}.wikipedia.org/wiki/{urllib.parse.quote(title.replace(' ', '_'))}"
343
+
344
+ results.append(schemas.SearchResult(
345
+ title=title,
346
+ url=article_url,
347
+ snippet=snippet,
348
+ image=None,
349
+ source="ويكيبيديا" if is_arabic else "Wikipedia"
350
+ ))
351
+
352
+ return results
353
+ except Exception as e:
354
+ import traceback
355
+ with open("error.log", "w", encoding="utf-8") as f:
356
+ f.write(traceback.format_exc())
357
+ raise HTTPException(status_code=500, detail=f"حدث خطأ أثناء البحث: {str(e)}")
app/models/models.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, Text, ForeignKey
2
+ from sqlalchemy.orm import relationship
3
+ from app.db.database import Base
4
+
5
+ class User(Base):
6
+ __tablename__ = "users"
7
+
8
+ id = Column(Integer, primary_key=True, index=True)
9
+ name = Column(String(100), nullable=False)
10
+
11
+ comments = relationship("Comment", back_populates="user")
12
+
13
+ class Article(Base):
14
+ __tablename__ = "articles"
15
+
16
+ id = Column(Integer, primary_key=True, index=True)
17
+ title = Column(String(255), nullable=False)
18
+ content = Column(Text, nullable=False)
19
+ category = Column(String(100))
20
+ author = Column(String(100))
21
+ image = Column(String(500))
22
+ summary = Column(Text, nullable=True)
23
+
24
+ comments = relationship("Comment", back_populates="article", cascade="all, delete-orphan")
25
+
26
+ class Comment(Base):
27
+ __tablename__ = "comments"
28
+
29
+ id = Column(Integer, primary_key=True, index=True)
30
+ text = Column(Text, nullable=False)
31
+ sentiment = Column(String(20), default="NEUTRAL") # POSITIVE, NEGATIVE, NEUTRAL
32
+ user_id = Column(Integer, ForeignKey("users.id"))
33
+ article_id = Column(Integer, ForeignKey("articles.id"))
34
+
35
+ user = relationship("User", back_populates="comments")
36
+ article = relationship("Article", back_populates="comments")
app/schemas/schemas.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional
3
+
4
+ # --- User Schemas ---
5
+ class UserBase(BaseModel):
6
+ id: int
7
+ name: str
8
+
9
+ class Config:
10
+ from_attributes = True
11
+
12
+ # --- Comment Schemas ---
13
+ class CommentCreate(BaseModel):
14
+ text: str
15
+ user: Optional[UserBase] = None
16
+ article: Optional[BaseModel] = None # We will handle nested article ID parsing in main
17
+
18
+ class Config:
19
+ from_attributes = True
20
+
21
+ class CommentResponse(BaseModel):
22
+ id: int
23
+ text: str
24
+ sentiment: str
25
+ user: Optional[UserBase] = None
26
+
27
+ class Config:
28
+ from_attributes = True
29
+
30
+ # --- Article Schemas ---
31
+ class ArticleBase(BaseModel):
32
+ id: int
33
+ title: str
34
+ content: str
35
+ category: Optional[str] = None
36
+ author: Optional[str] = None
37
+ image: Optional[str] = None
38
+ summary: Optional[str] = None
39
+
40
+ class Config:
41
+ from_attributes = True
42
+
43
+ class ArticleResponse(ArticleBase):
44
+ comments: List[CommentResponse] = []
45
+
46
+ class Config:
47
+ from_attributes = True
48
+
49
+ # --- Chat & Scraping Upgrades ---
50
+ class ScrapeRequest(BaseModel):
51
+ url: str
52
+
53
+ class ChatMessage(BaseModel):
54
+ role: str # "user" or "model"
55
+ content: str
56
+
57
+ class ChatRequest(BaseModel):
58
+ message: str
59
+ history: Optional[List[ChatMessage]] = None
60
+
61
+ # --- Web Search Schema ---
62
+ class SearchResult(BaseModel):
63
+ title: str
64
+ url: str
65
+ snippet: Optional[str] = None
66
+ image: Optional[str] = None
67
+ source: Optional[str] = None
app/services/scraper.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+ import re
4
+ from urllib.parse import urljoin, urlparse
5
+
6
+ def scrape_article_url(url: str) -> dict:
7
+ """
8
+ Scrapes a webpage URL and extracts:
9
+ - title
10
+ - content (main body text)
11
+ - author
12
+ - image (cover image)
13
+ - category
14
+ """
15
+ headers = {
16
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
17
+ }
18
+
19
+ try:
20
+ response = requests.get(url, headers=headers, timeout=10)
21
+ response.raise_for_status()
22
+
23
+ # Detect encoding
24
+ if response.encoding == 'ISO-8859-1':
25
+ response.encoding = response.apparent_encoding
26
+
27
+ soup = BeautifulSoup(response.text, "html.parser")
28
+
29
+ # 1. Extract Title
30
+ title = ""
31
+ # Try og:title first
32
+ og_title = soup.find("meta", property="og:title")
33
+ if og_title and og_title.get("content"):
34
+ title = og_title["content"]
35
+ else:
36
+ # Try <h1>
37
+ h1 = soup.find("h1")
38
+ if h1:
39
+ title = h1.get_text().strip()
40
+ else:
41
+ title_tag = soup.find("title")
42
+ if title_tag:
43
+ title = title_tag.get_text().strip()
44
+
45
+ if not title:
46
+ title = "مقال مستخلص من الويب"
47
+
48
+ # 2. Extract Author
49
+ author = "كاتب ويب"
50
+ author_meta = soup.find("meta", attrs={"name": "author"}) or soup.find("meta", property="article:author")
51
+ if author_meta and author_meta.get("content"):
52
+ author = author_meta["content"].strip()
53
+ else:
54
+ # Search for typical author classes
55
+ author_tag = soup.find(class_=re.compile(r"author|byline|writer", re.I))
56
+ if author_tag:
57
+ author = author_tag.get_text().strip()
58
+
59
+ # 3. Extract Cover Image
60
+ image_url = "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=600&auto=format&fit=crop&q=60" # default
61
+ og_image = soup.find("meta", property="og:image")
62
+ if og_image and og_image.get("content"):
63
+ image_url = og_image["content"]
64
+ else:
65
+ # Try finding the first large image in body
66
+ for img in soup.find_all("img"):
67
+ src = img.get("src")
68
+ if src and not src.endswith(".gif") and not src.endswith(".svg"):
69
+ # Resolve relative url
70
+ image_url = urljoin(url, src)
71
+ break
72
+
73
+ # 4. Extract Main Content
74
+ # Remove noisy elements
75
+ for element in soup(["script", "style", "nav", "footer", "header", "aside", "form"]):
76
+ element.extract()
77
+
78
+ # Find paragraphs
79
+ paragraphs = soup.find_all("p")
80
+ text_blocks = []
81
+ for p in paragraphs:
82
+ text = p.get_text().strip()
83
+ # Ignore short/noise paragraphs (less than 30 characters)
84
+ if len(text) > 30:
85
+ text_blocks.append(text)
86
+
87
+ content = "\n\n".join(text_blocks)
88
+
89
+ if not content:
90
+ # Fallback: get raw body text if no paragraphs are found
91
+ content = soup.body.get_text(separator="\n\n").strip() if soup.body else "تعذر استخلاص محتوى النص من هذا الموقع."
92
+ # Limit length if it's too raw and full of noise
93
+ content = content[:3000]
94
+
95
+ # 5. Extract/Guess Category or Domain Name
96
+ domain = urlparse(url).netloc.replace("www.", "")
97
+ category = domain.split(".")[0].capitalize()
98
+
99
+ return {
100
+ "title": title,
101
+ "content": content,
102
+ "author": author,
103
+ "image": image_url,
104
+ "category": category
105
+ }
106
+
107
+ except Exception as e:
108
+ import traceback
109
+ traceback.print_exc()
110
+ print(f"Scraping error: {e}")
111
+ return {
112
+ "title": "فشل جلب المقال",
113
+ "content": f"حدث خطأ أثناء محاولة الاتصال بالموقع أو جلب محتواه:\n{str(e)}",
114
+ "author": "خطأ النظام",
115
+ "image": "https://images.unsplash.com/photo-1594322436404-5a0526db4d13?w=600&auto=format&fit=crop&q=60",
116
+ "category": "خطأ"
117
+ }
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.8
2
+ uvicorn==0.34.0
3
+ sqlalchemy==2.0.38
4
+ pydantic==2.10.6
5
+ google-generativeai==0.8.4
6
+ beautifulsoup4==4.12.3
7
+ python-dotenv==1.0.1
8
+ requests==2.32.3
9
+ groq==0.13.1
10
+ python-multipart==0.0.20
11
+ PyPDF2==3.0.1
12
+ python-docx==1.1.2
scripts/seed.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ # Add parent directory to path so database modules can be imported
5
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+
7
+ from app.db.database import engine, SessionLocal, Base
8
+ from app.models import models
9
+
10
+ def seed_db():
11
+ # Recreate tables
12
+ Base.metadata.drop_all(bind=engine)
13
+ Base.metadata.create_all(bind=engine)
14
+
15
+ db = SessionLocal()
16
+ try:
17
+ # Create a test user with ID 1 (required by the frontend comment payload)
18
+ test_user = models.User(id=1, name="أحمد علي")
19
+ db.add(test_user)
20
+ db.commit()
21
+
22
+ # Add sample articles
23
+ articles = [
24
+ models.Article(
25
+ id=1,
26
+ title="مستقبل الذكاء الاصطناعي في الطب",
27
+ content="""شهد قطاع الطب ثورة هائلة بفضل تقنيات الذكاء الاصطناعي في الآونة الأخيرة.
28
+ تُستخدم خوارزميات التعلم العميق الآن في تشخيص الأمراض المعقدة مثل السرطان بدقة تتفوق أحياناً على أمهر الأطباء.
29
+ علاوة على ذلك، يساهم الذكاء الاصطناعي في تسريع عملية تطوير الأدوية الجديدة من خلال التنبؤ بكيفية تفاعل الجزيئات الكيميائية، مما يوفر سنوات من البحث السريري والملايين من الدولارات.
30
+ في المستقبل القريب، سنرى روبوتات جراحية تعمل بدعم كامل من الذكاء الاصطناعي لتقليل الأخطاء البشرية أثناء العمليات الحساسة.""",
31
+ category="تقنية وصحة",
32
+ author="د. سامي الجمال",
33
+ image="https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=600&auto=format&fit=crop&q=60",
34
+ summary="يستعرض المقال كيف يُحدث الذكاء الاصطناعي ثورة في قطاع الرعاية الصحية من خلال التشخيص الدقيق للأمراض وتطوير الأدوية وتوجيه الروبوتات الجراحية."
35
+ ),
36
+ models.Article(
37
+ id=2,
38
+ title="أسرار النوم الصحي وأثره على الإنتاجية",
39
+ content="""يعتقد الكثيرون أن النوم هو مجرد وقت مستقطع من اليوم للراحة، لكن الأبحاث الحديثة تؤكد أنه عملية حيوية بالغة الأهمية لتنظيف الدماغ من السموم المتراكمة طوال النهار.
40
+ الحصول على 7 إلى 8 ساعات من النوم العميق ليلاً يساعد على تحسين الذاكرة قصيرة المدى وزيادة القدرة على التركيز واتخاذ القرارات الصائبة في اليوم التالي.
41
+ على النقيض من ذلك، يؤدي الحرمان المزمن من النوم إلى تدهور الصحة النفسية وزيادة خطر الإصابة بأمراض القلب والسكري.
42
+ للحصول على نوم مثالي، يُنصح بالابتعاد عن الشاشات الزرقاء قبل ساعة من النوم وتثبيت موعد النوم والاستيقاظ يومياً.""",
43
+ category="نمط حياة وصحة",
44
+ author="منى الصاوي",
45
+ image="https://images.unsplash.com/photo-1511295742364-92767fa62d9f?w=600&auto=format&fit=crop&q=60",
46
+ summary="يتناول المقال الأهمية البيولوجية للنوم الصحي في تحسين التركيز والذاكرة وحماية الصحة العامة، مع تقديم نصائح عملية لنوم أفضل."
47
+ ),
48
+ models.Article(
49
+ id=3,
50
+ title="استكشاف الكواكب البعيدة: هل سنصل إليها يوماً؟",
51
+ content="""لطالما كان السفر عبر النجوم حلماً يراود البشرية منذ عقود.
52
+ مع اكتشاف آلاف الكواكب خارج مجموعتنا الشمسية (Exoplanets)، بدأ العلماء في البحث عن كواكب تقع في النطاق الصالح للحياة (Goldilocks zone) حيث يمكن للماء السائل أن يتواجد.
53
+ العقبة الكبرى التي تواجهنا هي المسافات الشاسعة؛ فأقرب كوكب يحتمل أن يكون صالحاً للحياة يبعد عنا حوالي 4 سنوات ضوئية، وهي مسافة تتطلب آلاف السنين للوصول إليها باستخدام تكنولوجيا الصواريخ الحالية.
54
+ تجرى حالياً أبحاث حول محركات الدفع الضوئي والمركبات النانوية فائقة السرعة التي قد تمهد الطريق لإرسال أولى المسابر البشرية إلى تلك العوالم البعيدة خلال هذا القرن.""",
55
+ category="علوم وفضاء",
56
+ author="م. رامي كمال",
57
+ image="https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=600&auto=format&fit=crop&q=60",
58
+ summary="يناقش المقال التحديات والآمال المتعلقة بالسفر لاستكشاف الكواكب البعيدة الصالحة للحياة خارج نظامنا الشمسي والتكنولوجيا المستقبلية المقترحة."
59
+ )
60
+ ]
61
+
62
+ db.add_all(articles)
63
+ db.commit()
64
+ print("Database seeded successfully with test data!")
65
+
66
+ except Exception as e:
67
+ print(f"Error seeding database: {e}")
68
+ db.rollback()
69
+ finally:
70
+ db.close()
71
+
72
+ if __name__ == "__main__":
73
+ seed_db()