Spaces:
Sleeping
Sleeping
| """ | |
| Q-Simplified β HuggingFace Space Entry Point | |
| Single FastAPI process serving: | |
| β’ Static frontend (index, login, trends, study-zone) | |
| β’ /api/market-data β live scraped financial indicators | |
| β’ /api/news β scraped RSS news headlines | |
| β’ /api/ai β Groq-powered market briefings & article summaries | |
| β’ /api/blogs β blog content (Supabase or demo) | |
| β’ /api/auth β register / login / JWT | |
| Port: 7860 (HuggingFace requirement) | |
| """ | |
| import asyncio | |
| import logging | |
| import os | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from typing import Optional | |
| import uvicorn | |
| from apscheduler.schedulers.asyncio import AsyncIOScheduler | |
| from apscheduler.triggers.interval import IntervalTrigger | |
| from dotenv import load_dotenv | |
| import uuid as _uuid | |
| from fastapi import FastAPI, HTTPException, Header, Request, UploadFile, File | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, EmailStr, Field | |
| from slowapi import Limiter, _rate_limit_exceeded_handler | |
| from slowapi.errors import RateLimitExceeded | |
| from slowapi.util import get_remote_address | |
| load_dotenv() | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| ) | |
| logger = logging.getLogger("qs-app") | |
| # βββ IMPORT LOCAL SERVICE MODULES βββββββββββββββββββββββββββββββββββββββββββββ | |
| from services import market_data, news_scraper, content, auth as auth_svc, ai_summarizer | |
| # βββ RATE LIMITER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| limiter = Limiter(key_func=get_remote_address, default_limits=["200/hour"]) | |
| # βββ SCHEDULER ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| scheduler = AsyncIOScheduler() | |
| # βββ LIFESPAN βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def lifespan(app: FastAPI): | |
| logger.info("π Q-Simplified starting up...") | |
| # Validate critical secrets at startup | |
| jwt_secret = os.getenv("JWT_SECRET", "") | |
| if not jwt_secret or len(jwt_secret) < 32: | |
| logger.warning("β οΈ JWT_SECRET is missing or too short β set a strong secret in env vars") | |
| if not os.getenv("GROQ_API_KEY"): | |
| logger.warning("β οΈ GROQ_API_KEY not set β AI features will use fallback responses") | |
| # Init Supabase connections (fall back gracefully if no env vars) | |
| content.init_supabase() | |
| auth_svc.init_supabase() | |
| # Initial data fetch | |
| await market_data.refresh() | |
| await news_scraper.refresh() | |
| asyncio.create_task(market_data.history_sync()) # Non-blocking warmup β don't delay first request | |
| # Initial AI briefing using first batch of headlines | |
| headlines = [a["title"] for a in news_scraper.get_articles(limit=15)] | |
| await ai_summarizer.refresh(headlines) | |
| # Schedule periodic refreshes | |
| scheduler.add_job(market_data.refresh, IntervalTrigger(seconds=60), id="market_refresh", replace_existing=True) | |
| scheduler.add_job(news_scraper.refresh, IntervalTrigger(minutes=5), id="news_refresh", replace_existing=True) | |
| scheduler.add_job(_refresh_ai_briefing, IntervalTrigger(hours=1), id="ai_refresh", replace_existing=True) | |
| scheduler.add_job(market_data.history_sync, IntervalTrigger(hours=1), id="history_sync", replace_existing=True) | |
| scheduler.start() | |
| logger.info("β Schedulers started: market(60s), news(5min), ai-briefing(1h)") | |
| yield # App is running | |
| scheduler.shutdown() | |
| logger.info("Q-Simplified shut down") | |
| async def _refresh_ai_briefing(): | |
| """Scheduler wrapper β pulls fresh headlines then calls AI.""" | |
| headlines = [a["title"] for a in news_scraper.get_articles(limit=15)] | |
| await ai_summarizer.refresh(headlines) | |
| # βββ APP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="Q-Simplified", | |
| description="The Financial Architect β Live on HuggingFace", | |
| version="1.1.0", | |
| lifespan=lifespan, | |
| docs_url="/api/docs", | |
| redoc_url=None, | |
| ) | |
| # Rate limiter exception handler | |
| app.state.limiter = limiter | |
| app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) | |
| # βββ CORS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # In production, set ALLOWED_ORIGINS env var to your actual domain(s). | |
| # Defaults to HuggingFace space URLs + localhost for dev. | |
| _raw_origins = os.getenv( | |
| "ALLOWED_ORIGINS", | |
| "https://srvcp-q-simplified.hf.space,http://localhost:3000,http://localhost:5173,http://127.0.0.1:7860" | |
| ) | |
| ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()] | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=ALLOWED_ORIGINS, | |
| allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], | |
| allow_headers=["Authorization", "Content-Type"], | |
| allow_credentials=True, | |
| ) | |
| STATIC_DIR = Path(__file__).parent / "static" | |
| # βββ AUTH HELPERS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_token(authorization: Optional[str] = None) -> dict: | |
| if not authorization or not authorization.startswith("Bearer "): | |
| raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") | |
| token = authorization.split(" ")[1] | |
| try: | |
| return auth_svc.verify_jwt(token) | |
| except ValueError as e: | |
| raise HTTPException(status_code=401, detail=str(e)) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AUTH ROUTES /api/auth/* | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RegisterBody(BaseModel): | |
| email: EmailStr | |
| password: str = Field(..., min_length=8) | |
| full_name: str = Field(..., min_length=2) | |
| class LoginBody(BaseModel): | |
| email: EmailStr | |
| password: str | |
| class ForgotBody(BaseModel): | |
| email: EmailStr | |
| class BlogCreateBody(BaseModel): | |
| title: str = Field(..., min_length=5, max_length=200) | |
| content: str = Field(..., min_length=50) | |
| category: str = Field(..., min_length=2, max_length=80) | |
| tags: list[str] = Field(default_factory=list) | |
| excerpt: Optional[str] = Field(default=None, max_length=280) | |
| image_url: Optional[str] = Field(default=None, max_length=500) | |
| featured: bool = False | |
| published: bool = True | |
| class BlogUpdateBody(BaseModel): | |
| title: Optional[str] = Field(None, min_length=5, max_length=200) | |
| content: Optional[str] = Field(None, min_length=50) | |
| category: Optional[str] = Field(None, min_length=2, max_length=80) | |
| excerpt: Optional[str] = Field(None, max_length=280) | |
| tags: Optional[list[str]] = None | |
| image_url: Optional[str] = Field(None, max_length=500) | |
| featured: Optional[bool] = None | |
| published: Optional[bool] = None | |
| async def health(): | |
| return { | |
| "status": "ok", | |
| "app": "q-simplified", | |
| "version": "1.1.0", | |
| "market_data": bool(market_data.get_cached()), | |
| "news_articles": len(news_scraper.get_articles()), | |
| "ai_briefing": bool(ai_summarizer.get_cached().get("briefing")), | |
| "ai_source": ai_summarizer.get_cached().get("source", "none"), | |
| } | |
| async def register(request: Request, body: RegisterBody): | |
| try: | |
| return auth_svc.register(body.email, body.password, body.full_name) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: | |
| logger.error(f"Register error: {e}") | |
| raise HTTPException(status_code=500, detail="Registration failed") | |
| async def login(request: Request, body: LoginBody): | |
| try: | |
| return auth_svc.login(body.email, body.password) | |
| except ValueError as e: | |
| raise HTTPException(status_code=401, detail=str(e)) | |
| except Exception as e: | |
| logger.error(f"Login error: {e}") | |
| raise HTTPException(status_code=500, detail="Login failed") | |
| async def logout(): | |
| return {"message": "Logged out"} | |
| async def me(authorization: Optional[str] = Header(None)): | |
| payload = get_token(authorization) | |
| return {"id": payload["sub"], "email": payload["email"], "role": payload.get("role", "reader")} | |
| async def forgot_password(request: Request, body: ForgotBody): | |
| auth_svc.send_reset_email(body.email) | |
| return {"message": "If an account exists, a reset link has been sent"} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MARKET DATA ROUTES /api/market-data/* | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def market_latest(request: Request): | |
| return market_data.get_cached() | |
| async def market_force_refresh(request: Request): | |
| """Manually trigger a refresh (for testing).""" | |
| await market_data.refresh() | |
| return {"status": "refreshed", "data": market_data.get_cached()} | |
| _PERIOD_INTERVAL = {"1d": "15m", "5d": "60m", "1mo": "1d", "3mo": "1d", "1y": "1d"} | |
| async def market_history(request: Request, symbol: str, period: str = "1mo", interval: Optional[str] = None): | |
| from urllib.parse import unquote | |
| symbol = unquote(symbol) | |
| if symbol not in market_data.HISTORY_SYMBOLS: | |
| raise HTTPException(status_code=400, detail=f"Symbol not supported. Allowed: {list(market_data.HISTORY_SYMBOLS.keys())}") | |
| if period not in _PERIOD_INTERVAL: | |
| raise HTTPException(status_code=400, detail=f"period must be one of {sorted(_PERIOD_INTERVAL.keys())}") | |
| canonical_interval = _PERIOD_INTERVAL[period] | |
| if interval and interval != canonical_interval: | |
| raise HTTPException(status_code=400, detail=f"For period '{period}' interval must be '{canonical_interval}'") | |
| data = await market_data.fetch_history(symbol, period, canonical_interval) | |
| return {"symbol": symbol, "period": period, "interval": canonical_interval, "data": data} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # NEWS ROUTES /api/news/* | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def live_insights(request: Request): | |
| return { | |
| "insights": news_scraper.get_insights(), | |
| "last_updated": news_scraper.get_cached().get("last_updated"), | |
| } | |
| async def news_latest(request: Request, limit: int = 20, category: Optional[str] = None): | |
| articles = news_scraper.get_articles(limit=limit, category=category) | |
| return { | |
| "articles": articles, | |
| "total": len(articles), | |
| "last_updated": news_scraper.get_cached().get("last_updated"), | |
| } | |
| async def news_categories(): | |
| articles = news_scraper.get_articles(limit=200) | |
| cats = sorted(set(a["category"] for a in articles)) | |
| return {"categories": cats} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AI ROUTES /api/ai/* | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def ai_market_briefing(request: Request): | |
| """ | |
| Returns a Groq-generated plain-English market briefing. | |
| Cached and refreshed every hour by the scheduler. | |
| """ | |
| return ai_summarizer.get_cached() | |
| async def ai_briefing_force_refresh(request: Request): | |
| """Force-refresh the AI briefing (rate-limited).""" | |
| headlines = [a["title"] for a in news_scraper.get_articles(limit=15)] | |
| await ai_summarizer.refresh(headlines) | |
| return ai_summarizer.get_cached() | |
| async def summarize_article(request: Request, body: dict): | |
| """ | |
| Summarize a single article into 2 plain-English sentences. | |
| Body: { "title": "...", "content": "..." } | |
| """ | |
| title = body.get("title", "") | |
| content_text = body.get("content", "") | |
| if not title and not content_text: | |
| raise HTTPException(status_code=400, detail="Provide title or content") | |
| summary = await ai_summarizer.summarize_article(title, content_text) | |
| return {"summary": summary} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # BLOG ROUTES /api/blogs/* | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def blogs_list(request: Request, limit: int = 20, offset: int = 0, category: Optional[str] = None): | |
| blogs = content.get_blogs(limit=limit, offset=offset, category=category) | |
| return {"blogs": blogs, "total": len(blogs), "offset": offset, "limit": limit} | |
| async def blogs_create(request: Request, body: BlogCreateBody, authorization: Optional[str] = Header(None)): | |
| payload = get_token(authorization) | |
| role = auth_svc.get_user_role(payload["sub"]) | |
| if role == "reader": | |
| raise HTTPException(status_code=403, detail="Readers cannot create posts") | |
| try: | |
| blog = content.create_blog( | |
| title=body.title, | |
| content=body.content, | |
| category=body.category, | |
| tags=body.tags, | |
| excerpt=body.excerpt, | |
| image_url=body.image_url, | |
| featured=body.featured if role == "admin" else False, | |
| published=body.published, | |
| author_id=payload["sub"], | |
| author_email=payload["email"], | |
| ) | |
| return {"blog": blog} | |
| except ValueError as e: | |
| detail = str(e) | |
| if detail == "Supabase is not configured": | |
| raise HTTPException(status_code=503, detail=detail) | |
| raise HTTPException(status_code=400, detail=detail) | |
| async def blogs_featured(): | |
| return {"blog": content.get_featured()} | |
| async def blogs_recent(limit: int = 3): | |
| return {"blogs": content.get_recent(limit=limit)} | |
| async def blogs_categories(): | |
| return {"categories": content.get_categories()} | |
| async def blogs_mine(request: Request, authorization: Optional[str] = Header(None), published: Optional[bool] = None): | |
| payload = get_token(authorization) | |
| role = auth_svc.get_user_role(payload["sub"]) | |
| blogs = content.get_user_blogs(payload["sub"], is_admin=(role == "admin")) | |
| if published is not None: | |
| blogs = [b for b in blogs if b.get("published") == published] | |
| return {"blogs": blogs, "total": len(blogs)} | |
| async def blogs_update(request: Request, slug: str, body: BlogUpdateBody, authorization: Optional[str] = Header(None)): | |
| payload = get_token(authorization) | |
| role = auth_svc.get_user_role(payload["sub"]) | |
| if role == "reader": | |
| raise HTTPException(status_code=403, detail="Readers cannot edit posts") | |
| try: | |
| update_fields = body.model_dump(exclude_none=True) | |
| if update_fields.get("featured") and role != "admin": | |
| del update_fields["featured"] # only admins may promote posts | |
| blog = content.update_blog(slug=slug, author_id=payload["sub"], role=role, **update_fields) | |
| return {"blog": blog} | |
| except PermissionError: | |
| raise HTTPException(status_code=403, detail="You do not have permission to edit this post") | |
| except ValueError as e: | |
| detail = str(e) | |
| if detail == "not found": | |
| raise HTTPException(status_code=404, detail="Blog post not found") | |
| if detail == "Supabase is not configured": | |
| raise HTTPException(status_code=503, detail=detail) | |
| raise HTTPException(status_code=400, detail=detail) | |
| async def blogs_delete(request: Request, slug: str, authorization: Optional[str] = Header(None)): | |
| payload = get_token(authorization) | |
| role = auth_svc.get_user_role(payload["sub"]) | |
| if role == "reader": | |
| raise HTTPException(status_code=403, detail="Readers cannot delete posts") | |
| try: | |
| content.delete_blog(slug=slug, author_id=payload["sub"], role=role) | |
| except PermissionError: | |
| raise HTTPException(status_code=403, detail="You do not have permission to delete this post") | |
| except ValueError as e: | |
| detail = str(e) | |
| if detail == "not found": | |
| raise HTTPException(status_code=404, detail="Blog post not found") | |
| if detail == "Supabase is not configured": | |
| raise HTTPException(status_code=503, detail=detail) | |
| raise HTTPException(status_code=400, detail=detail) | |
| _ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} | |
| async def upload_image(request: Request, file: UploadFile = File(...), authorization: Optional[str] = Header(None)): | |
| payload = get_token(authorization) | |
| role = auth_svc.get_user_role(payload["sub"]) | |
| if role == "reader": | |
| raise HTTPException(status_code=403, detail="Readers cannot upload images") | |
| if file.content_type not in _ALLOWED_IMAGE_TYPES: | |
| raise HTTPException(status_code=415, detail="Unsupported file type. Allowed: jpeg, png, webp") | |
| file_bytes = await file.read() | |
| if len(file_bytes) > 5 * 1024 * 1024: | |
| raise HTTPException(status_code=413, detail="File too large. Maximum size is 5 MB") | |
| try: | |
| url = content.upload_blog_image(file_bytes, file.content_type, payload["sub"]) | |
| return {"url": url} | |
| except ValueError as e: | |
| detail = str(e) | |
| if "not configured" in detail or "upload failed" in detail.lower(): | |
| raise HTTPException(status_code=503, detail="Storage service unavailable") | |
| raise HTTPException(status_code=500, detail=detail) | |
| async def blog_detail(slug: str, authorization: Optional[str] = Header(None)): | |
| blog = content.get_blog(slug) | |
| if not blog: | |
| raise HTTPException(status_code=404, detail="Blog post not found") | |
| if not blog.get("published"): | |
| # Draft: only the author or an admin may view it | |
| try: | |
| payload = get_token(authorization) | |
| role = auth_svc.get_user_role(payload["sub"]) | |
| if role != "admin" and payload["sub"] != blog.get("author_id"): | |
| raise HTTPException(status_code=404, detail="Blog post not found") | |
| except HTTPException as exc: | |
| if exc.status_code == 401: | |
| raise HTTPException(status_code=404, detail="Blog post not found") | |
| raise | |
| return blog | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STATIC FILE SERVING β frontend HTML pages | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Mount JS assets | |
| if (STATIC_DIR / "js").exists(): | |
| app.mount("/js", StaticFiles(directory=str(STATIC_DIR / "js")), name="js") | |
| # Named HTML routes | |
| HTML_ROUTES = { | |
| "/": "index.html", | |
| "/login": "login.html", | |
| "/blog-uploader": "blog-uploader.html", | |
| "/my-blogs": "my-blogs.html", | |
| "/trends": "trends.html", | |
| "/study-zone": "study-zone.html", | |
| "/test-series": "test-series.html", | |
| "/book-class": "book-class.html", | |
| "/simplified-zone": "simplified-zone.html", | |
| "/about": "about.html", | |
| } | |
| for route, filename in HTML_ROUTES.items(): | |
| filepath = STATIC_DIR / filename | |
| def make_handler(fp): | |
| async def handler(): | |
| if fp.exists(): | |
| return FileResponse(str(fp)) | |
| return FileResponse(str(STATIC_DIR / "index.html")) | |
| return handler | |
| app.get(route)(make_handler(filepath)) | |
| async def serve_html_ext(page: str): | |
| fp = STATIC_DIR / f"{page}.html" | |
| if fp.exists(): | |
| return FileResponse(str(fp)) | |
| return FileResponse(str(STATIC_DIR / "index.html")) | |
| # βββ ENTRY POINT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| port = int(os.getenv("PORT", 7860)) | |
| logger.info(f"Starting Q-Simplified on port {port}") | |
| uvicorn.run( | |
| "app:app", | |
| host="0.0.0.0", | |
| port=port, | |
| log_level="info", | |
| access_log=True, | |
| ) | |