Spaces:
Sleeping
Sleeping
| """ | |
| Annator full stack for Hugging Face Docker Spaces. | |
| - FastAPI backend (main_api_app) on one process | |
| - AIMONEYFLOW + client HTML static frontend | |
| - Listens on 0.0.0.0:7860 | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import sys | |
| from pathlib import Path | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", | |
| ) | |
| log = logging.getLogger("annator-hf") | |
| ROOT = Path(__file__).resolve().parent | |
| BACKEND = ROOT / "backend" | |
| FRONTEND = ROOT / "frontend" | |
| # --- HF / Space environment defaults (before backend import) --- | |
| os.environ.setdefault("ENVIRONMENT", "development") # keep /docs available | |
| os.environ.setdefault("ALLOWED_HOSTS", "*") | |
| os.environ.setdefault( | |
| "ALLOWED_ORIGINS", | |
| "*,https://techprotrade-annator-atom.hf.space,http://localhost:7860", | |
| ) | |
| if not os.environ.get('DATABASE_URL') and os.environ.get('NEON_DATABASE_URL'): | |
| os.environ['DATABASE_URL'] = os.environ['NEON_DATABASE_URL'] | |
| os.environ.setdefault("DATABASE_URL", f"sqlite:///{(ROOT / 'data' / 'atom.db').as_posix()}") | |
| os.environ.setdefault("SKIP_USER_BOOTSTRAP", "true") | |
| os.environ.setdefault("ATOM_MOCK_DATABASE", "false") | |
| os.environ.setdefault("HF_SPACE", "1") | |
| os.environ.setdefault("PORT", "7860") | |
| (ROOT / "data").mkdir(parents=True, exist_ok=True) | |
| if str(BACKEND) not in sys.path: | |
| sys.path.insert(0, str(BACKEND)) | |
| os.chdir(BACKEND) | |
| app = None | |
| mode = "unknown" | |
| try: | |
| from main_api_app import app as _app # type: ignore | |
| app = _app | |
| mode = "full" | |
| log.info("Loaded main_api_app (full backend)") | |
| except Exception as exc: # noqa: BLE001 | |
| log.exception("Full backend failed to import: %s", exc) | |
| try: | |
| from main_api_app_safe import app as _app # type: ignore | |
| app = _app | |
| mode = "safe" | |
| log.warning("Fell back to main_api_app_safe") | |
| except Exception as exc2: # noqa: BLE001 | |
| log.exception("Safe backend also failed: %s", exc2) | |
| from fastapi import FastAPI | |
| app = FastAPI(title="Annator Atom (degraded)") | |
| mode = "degraded" | |
| def _deg_health(): | |
| return {"status": "degraded", "error": str(exc), "fallback_error": str(exc2)} | |
| from fastapi import HTTPException | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| # Drop TrustedHost so HF Space host is accepted | |
| try: | |
| from starlette.middleware.trustedhost import TrustedHostMiddleware | |
| app.user_middleware = [ # type: ignore[attr-defined] | |
| m | |
| for m in getattr(app, "user_middleware", []) | |
| if getattr(m, "cls", None) is not TrustedHostMiddleware | |
| ] | |
| app.middleware_stack = None | |
| except Exception as exc: # noqa: BLE001 | |
| log.warning("Could not adjust TrustedHostMiddleware: %s", exc) | |
| # Final response headers for HF iframe + CSP (runs outermost when added last) | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| from starlette.requests import Request as StarletteRequest | |
| from starlette.responses import Response as StarletteResponse | |
| class HfBrowserCompatMiddleware(BaseHTTPMiddleware): | |
| CSP = ( | |
| "default-src 'self'; " | |
| "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net blob:; " | |
| "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " | |
| "font-src 'self' https://fonts.gstatic.com data:; " | |
| "img-src 'self' data: https: blob:; " | |
| "connect-src 'self' https: wss: ws:; " | |
| "worker-src 'self' blob:; " | |
| "frame-ancestors 'self' https://huggingface.co https://*.huggingface.co https://*.hf.space; " | |
| "base-uri 'self'; " | |
| "object-src 'none'" | |
| ) | |
| async def dispatch(self, request: StarletteRequest, call_next): | |
| response: StarletteResponse = await call_next(request) | |
| # Remove frame deny so Space can load in HF shell iframe | |
| if "x-frame-options" in response.headers: | |
| del response.headers["x-frame-options"] | |
| response.headers["Content-Security-Policy"] = self.CSP | |
| response.headers.setdefault("X-Content-Type-Options", "nosniff") | |
| return response | |
| app.add_middleware(HfBrowserCompatMiddleware) | |
| # Guarantee PDF Workflow Hub is mounted even if main_api_app import order skipped it | |
| try: | |
| from api.pdf_workflow_routes import router as _pdf_hub # type: ignore | |
| already = any( | |
| getattr(r, "path", "").startswith("/api/pdf") | |
| for r in app.router.routes | |
| ) | |
| if not already: | |
| app.include_router(_pdf_hub) | |
| log.info("PDF Workflow Hub attached from serve.py") | |
| else: | |
| log.info("PDF Workflow Hub already present on app") | |
| except Exception as exc: # noqa: BLE001 | |
| log.warning("Could not attach PDF Workflow Hub: %s", exc) | |
| def _strip_root_routes() -> None: | |
| """main_api_app registers GET / — remove so frontend can own the landing page.""" | |
| kept = [] | |
| removed = 0 | |
| for route in list(app.router.routes): | |
| path = getattr(route, "path", None) | |
| methods = getattr(route, "methods", None) or set() | |
| if path == "/" and (not methods or "GET" in methods or "HEAD" in methods): | |
| removed += 1 | |
| continue | |
| kept.append(route) | |
| if removed: | |
| app.router.routes = kept | |
| log.info("Removed %s existing root route(s) for frontend landing", removed) | |
| _strip_root_routes() | |
| async def hf_status(): | |
| return { | |
| "status": "ok", | |
| "mode": mode, | |
| "frontend": FRONTEND.exists(), | |
| "backend_path": str(BACKEND), | |
| "port": int(os.getenv("PORT", "7860")), | |
| "space": "techprotrade/annator-atom", | |
| } | |
| async def health(): | |
| return {"status": "ok", "mode": mode, "service": "annator-full-hf"} | |
| async def platform_info(): | |
| """Former root JSON payload still available under /api/platform.""" | |
| return { | |
| "name": "ATOM Platform API", | |
| "version": "2.1.0", | |
| "status": "running", | |
| "mode": mode, | |
| "docs": "/docs", | |
| "frontend": "/", | |
| "clients": "/clients/", | |
| } | |
| # Static frontend (API routes already registered — these come after) | |
| if FRONTEND.exists(): | |
| assets = FRONTEND / "assets" | |
| clients = FRONTEND / "clients" | |
| if assets.exists(): | |
| app.mount("/assets", StaticFiles(directory=str(assets)), name="assets") | |
| if clients.exists(): | |
| app.mount("/clients", StaticFiles(directory=str(clients), html=True), name="clients") | |
| index_file = FRONTEND / "index.html" | |
| async def spa_root(): | |
| if index_file.exists(): | |
| return FileResponse(index_file) | |
| return JSONResponse( | |
| {"message": "Annator Atom", "mode": mode, "hint": "/docs", "clients": "/clients/"} | |
| ) | |
| # Explicit HTML pages from AIMONEYFLOW | |
| for html_path in FRONTEND.glob("*.html"): | |
| name = html_path.name | |
| async def _serve_html(path: Path = html_path): # noqa: B023 | |
| return FileResponse(path) | |
| # Register /dashboard.html etc. (avoid double-register index) | |
| if name != "index.html": | |
| app.add_api_route(f"/{name}", _serve_html, methods=["GET"], name=f"html_{name}") | |
| # Fallback for other static files under frontend (css/js next to html) | |
| async def frontend_fallback(filename: str): | |
| # Never shadow API / docs | |
| if ( | |
| filename.startswith("api/") | |
| or filename.startswith("docs") | |
| or filename.startswith("redoc") | |
| or filename.startswith("openapi") | |
| or filename.startswith("health") | |
| ): | |
| raise HTTPException(status_code=404, detail="Not found") | |
| candidate = (FRONTEND / filename).resolve() | |
| try: | |
| candidate.relative_to(FRONTEND.resolve()) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=404, detail="Not found") from exc | |
| if candidate.is_file(): | |
| return FileResponse(candidate) | |
| raise HTTPException(status_code=404, detail="Not found") | |
| log.info("Mounted frontend from %s", FRONTEND) | |
| else: | |
| log.warning("Frontend directory missing: %s", FRONTEND) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| port = int(os.getenv("PORT", "7860")) | |
| uvicorn.run("serve:app", host="0.0.0.0", port=port, factory=False) | |