""" 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 ───────────────────────────────────────────────────────────────── @asynccontextmanager 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 @app.get("/api/health") 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"), } @app.post("/api/auth/register", status_code=201) @limiter.limit("5/minute") 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") @app.post("/api/auth/login") @limiter.limit("10/minute") 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") @app.post("/api/auth/logout") async def logout(): return {"message": "Logged out"} @app.get("/api/auth/me") async def me(authorization: Optional[str] = Header(None)): payload = get_token(authorization) return {"id": payload["sub"], "email": payload["email"], "role": payload.get("role", "reader")} @app.post("/api/auth/forgot-password") @limiter.limit("3/minute") 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/* # ══════════════════════════════════════════════════════════════════════════════ @app.get("/api/market-data/latest") @limiter.limit("60/minute") async def market_latest(request: Request): return market_data.get_cached() @app.get("/api/market-data/refresh") @limiter.limit("5/minute") 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"} @app.get("/api/market-data/history/{symbol}") @limiter.limit("30/minute") 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/* # ══════════════════════════════════════════════════════════════════════════════ @app.get("/api/news/live-insights") @limiter.limit("30/minute") async def live_insights(request: Request): return { "insights": news_scraper.get_insights(), "last_updated": news_scraper.get_cached().get("last_updated"), } @app.get("/api/news/latest") @limiter.limit("30/minute") 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"), } @app.get("/api/news/categories") 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/* # ══════════════════════════════════════════════════════════════════════════════ @app.get("/api/ai/market-briefing") @limiter.limit("20/minute") 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() @app.get("/api/ai/market-briefing/refresh") @limiter.limit("3/minute") 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() @app.post("/api/ai/summarize-article") @limiter.limit("10/minute") 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/* # ══════════════════════════════════════════════════════════════════════════════ @app.get("/api/blogs") @limiter.limit("30/minute") 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} @app.post("/api/blogs", status_code=201) @limiter.limit("10/minute") 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) @app.get("/api/blogs/featured") async def blogs_featured(): return {"blog": content.get_featured()} @app.get("/api/blogs/recent") async def blogs_recent(limit: int = 3): return {"blogs": content.get_recent(limit=limit)} @app.get("/api/blogs/categories/list") async def blogs_categories(): return {"categories": content.get_categories()} @app.get("/api/blogs/mine") @limiter.limit("30/minute") 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)} @app.put("/api/blogs/{slug}") @limiter.limit("20/minute") 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) @app.delete("/api/blogs/{slug}", status_code=204) @limiter.limit("20/minute") 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"} @app.post("/api/upload/image") @limiter.limit("10/minute") 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) @app.get("/api/blogs/{slug}") 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)) @app.get("/{page}.html") 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, )