| from collections.abc import AsyncIterator |
| from contextlib import asynccontextmanager |
| import logging |
| import time |
|
|
| from fastapi import FastAPI, Request |
| from fastapi.exceptions import RequestValidationError |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import JSONResponse, Response |
| from starlette.exceptions import HTTPException as StarletteHTTPException |
|
|
| from app.core import rate_limiter |
| from app.core.auth import get_verified_auth_subject |
| from app.core.config import get_settings |
| from app.core.database import init_db |
| from app.core.logging_config import setup_json_logging |
| from app.core.sanitizer import sanitize_error_detail |
| from app.routes import ( |
| admin, |
| ask, |
| auth, |
| billing, |
| chat, |
| chat_history, |
| chemistry_video, |
| dashboard, |
| dev, |
| documents, |
| flashcards, |
| generations, |
| generated_media, |
| generate_studio, |
| health, |
| intelligence, |
| learning_engine, |
| learning_state, |
| previous_papers, |
| pyq, |
| pyq_discovery, |
| physics_video, |
| quizzes, |
| social_science_video, |
| sources, |
| study, |
| study_path, |
| study_profile, |
| student_workspace, |
| tuition_profile, |
| tuition_brain, |
| transcription, |
| class_session_progress, |
| phase3_activity, |
| learn_anything_roadmap, |
| support, |
| sync, |
| telemetry, |
| users, |
| video, |
| video_generator, |
| jobs, |
| ) |
| from app.services.ai_provider import AIProviderError, log_ai_mode |
|
|
|
|
| settings = get_settings() |
|
|
|
|
| def _init_sentry() -> None: |
| """Initialize Sentry error tracking when SENTRY_DSN is configured. |
| |
| No-op (zero dependency cost) when the DSN is unset, so dev/local runs are |
| unaffected. Captures unhandled exceptions, slow transactions, and request |
| context for every API error. |
| """ |
| _log = logging.getLogger("docdoe.backend") |
| dsn = (settings.sentry_dsn or "").strip() |
| if not dsn: |
| return |
| try: |
| import sentry_sdk |
| from sentry_sdk.integrations.fastapi import FastApiIntegration |
| from sentry_sdk.integrations.starlette import StarletteIntegration |
|
|
| sentry_sdk.init( |
| dsn=dsn, |
| environment=settings.environment, |
| traces_sample_rate=settings.sentry_traces_sample_rate, |
| send_default_pii=False, |
| integrations=[StarletteIntegration(), FastApiIntegration()], |
| ) |
| _log.info("Sentry error tracking active (env=%s).", settings.environment) |
| except Exception as exc: |
| _log.warning("Sentry init skipped (%s).", type(exc).__name__) |
|
|
|
|
| _init_sentry() |
|
|
| |
| |
| _RATE_LIMIT_PREFIXES = ( |
| "/ask", |
| "/auth/login", |
| "/auth/signup", |
| "/auth/forgot-password", |
| "/auth/reset-password", |
| "/chat", |
| "/generate/", |
| "/intelligence/", |
| "/video/scene-plan", |
| "/video/generate-audio", |
| "/video/render-final", |
| "/video/render-jobs", |
| "/video/study-video-jobs", |
| "/video-generator/plan", |
| "/study-path/generate", |
| "/support", |
| "/sources", |
| "/record-lecture", |
| "/telemetry", |
| ) |
|
|
| |
| |
|
|
|
|
| @asynccontextmanager |
| async def lifespan(_: FastAPI) -> AsyncIterator[None]: |
| from app.core.database import engine |
| from app.utils.request_id import setup_request_id_logging |
|
|
| setup_json_logging() |
| setup_request_id_logging() |
| _startup_safety_checks() |
| settings.resolved_upload_dir.mkdir(parents=True, exist_ok=True) |
| settings.resolved_tts_output_dir.mkdir(parents=True, exist_ok=True) |
| settings.resolved_generated_video_jobs_dir.mkdir(parents=True, exist_ok=True) |
| settings.resolved_generated_video_output_dir.mkdir(parents=True, exist_ok=True) |
| init_db() |
| _reap_orphan_video_jobs() |
| log_ai_mode() |
| try: |
| yield |
| finally: |
| engine.dispose() |
|
|
|
|
| def _reap_orphan_video_jobs() -> None: |
| """Mark in-progress jobs left from a previous process as failed. |
| |
| A render runs as a FastAPI BackgroundTask — if the worker was killed mid-render |
| the job row stays in ``running`` forever and the student sees a spinner that |
| never resolves. On startup we reconcile that state once. |
| """ |
| from datetime import datetime, timezone |
|
|
| from app.core.database import SessionLocal |
| from app.models.video_render_job import VideoRenderJob |
|
|
| try: |
| with SessionLocal() as db: |
| stuck = list( |
| db.query(VideoRenderJob) |
| .filter( |
| VideoRenderJob.status.in_( |
| ["queued", "running", "audio_generating", "visuals_generating", "rendering", "uploading"], |
| ), |
| ) |
| .all() |
| ) |
| if not stuck: |
| return |
| now = datetime.now(timezone.utc) |
| for job in stuck: |
| job.status = "failed" |
| job.error_message = ( |
| "Render did not finish before the server restarted. " |
| "Please try again." |
| ) |
| job.completed_at = now |
| db.commit() |
| logger.warning( |
| "Reaped %d orphan video render job(s) from previous process", |
| len(stuck), |
| ) |
| except Exception: |
| |
| logger.exception("Orphan video job reaper failed") |
|
|
|
|
| def _startup_safety_checks() -> None: |
| """Warn loudly (or abort) when a production deploy has unsafe defaults.""" |
| current_settings = get_settings() |
| is_prod = current_settings.environment == "production" |
|
|
| |
| uses_local_jwt = (current_settings.auth_provider or "jwt").strip().lower() == "jwt" |
| if uses_local_jwt and ( |
| not current_settings.jwt_secret_key or len(current_settings.jwt_secret_key) < 32 |
| ): |
| raise RuntimeError( |
| "JWT_SECRET_KEY must be a unique 32+ character secret whenever AUTH_PROVIDER=jwt." |
| ) |
|
|
| uses_supabase_auth = (current_settings.auth_provider or "jwt").strip().lower() == "supabase" |
| if uses_supabase_auth and ( |
| not current_settings.supabase_url |
| or not current_settings.supabase_jwt_secret |
| or len(current_settings.supabase_jwt_secret) < 32 |
| ): |
| raise RuntimeError( |
| "SUPABASE_URL and a unique 32+ character SUPABASE_JWT_SECRET are required " |
| "whenever AUTH_PROVIDER=supabase." |
| ) |
|
|
| |
| if not current_settings.auth_enabled and not ( |
| current_settings.environment == "development" |
| and current_settings.allow_insecure_dev_auth |
| and current_settings.frontend_base_url.startswith(("http://localhost", "http://127.0.0.1")) |
| ): |
| logger.error( |
| "SECURITY: AUTH_ENABLED is false in production. " |
| "Every API request will be served as the dev/demo user — " |
| "real user data is completely unprotected. " |
| "Set AUTH_ENABLED=true and AUTH_PROVIDER=jwt in your environment." |
| ) |
| raise RuntimeError( |
| "AUTH_ENABLED must be true in production. " |
| "Set AUTH_ENABLED=true and AUTH_PROVIDER=jwt in backend/.env (or hosting secrets)." |
| ) |
|
|
| |
| _localhost_prefixes = ("http://localhost", "http://127.0.0.1", "http://0.0.0.0") |
| if is_prod and current_settings.frontend_base_url.startswith(_localhost_prefixes): |
| logger.error( |
| "CONFIG ERROR: FRONTEND_BASE_URL is '%s' in production. " |
| "Google OAuth callbacks and all frontend redirects will be sent to localhost " |
| "instead of the real app URL. Set FRONTEND_BASE_URL to your production domain.", |
| current_settings.frontend_base_url, |
| ) |
| raise RuntimeError( |
| f"FRONTEND_BASE_URL='{current_settings.frontend_base_url}' must be the production " |
| "domain (e.g. https://docdoe.ai) when ENVIRONMENT=production." |
| ) |
|
|
| |
| google_vars = { |
| "GOOGLE_CLIENT_ID": current_settings.google_client_id, |
| "GOOGLE_CLIENT_SECRET": current_settings.google_client_secret, |
| } |
| configured = [k for k, v in google_vars.items() if v] |
| missing = [k for k, v in google_vars.items() if not v] |
| if configured and missing: |
| |
| logger.warning( |
| "CONFIG WARNING: Google OAuth is partially configured. " |
| "%s is set but %s is missing. Google sign-in will not work until both are set.", |
| ", ".join(configured), |
| ", ".join(missing), |
| ) |
|
|
| |
| if is_prod and current_settings.database_url.startswith("sqlite"): |
| logger.error( |
| "CONFIG ERROR: DATABASE_URL is SQLite in production. SQLite is for local " |
| "development only and is not safe for beta users, multiple concurrent users, " |
| "or multiple uvicorn workers. Set DATABASE_URL to a PostgreSQL connection string." |
| ) |
| raise RuntimeError( |
| "DATABASE_URL must be PostgreSQL in production. " |
| "Set DATABASE_URL to a managed PostgreSQL connection string before serving real students." |
| ) |
|
|
| |
| if is_prod: |
| cors_list = [o.strip() for o in current_settings.cors_origins.split(",") if o.strip()] |
| local_cors = [ |
| o for o in cors_list |
| if o.startswith(("http://localhost", "http://127.0.0.1", "http://0.0.0.0")) |
| or o == "*" |
| ] |
| if local_cors: |
| logger.error( |
| "SECURITY: CORS_ORIGINS contains localhost or wildcard entries in production: %s. " |
| "Requests from the real frontend will fail or be insecure. " |
| "Set CORS_ORIGINS to the exact production frontend origin(s).", |
| ", ".join(local_cors), |
| ) |
| raise RuntimeError( |
| f"CORS_ORIGINS contains unsafe values for production: {local_cors}. " |
| "Set CORS_ORIGINS to your production frontend URL (e.g. https://docdoe.ai)." |
| ) |
|
|
| |
| if current_settings.environment != "development" and not current_settings.rate_limit_enabled: |
| raise RuntimeError( |
| "RATE_LIMIT_ENABLED must be true outside local development. " |
| "Per-user AI quota is not enforced — a single user can exhaust Sarvam credits. " |
| "Set RATE_LIMIT_ENABLED=true and RATE_LIMIT_AI_REQUESTS_PER_DAY=50." |
| ) |
|
|
| |
| if current_settings.beta_access_enabled and not current_settings.beta_invite_code: |
| if is_prod: |
| logger.error( |
| "CONFIG ERROR: BETA_ACCESS_ENABLED=true but BETA_INVITE_CODE is empty. " |
| "Every signup attempt will return 'Beta signup is currently closed.' " |
| "Set BETA_INVITE_CODE to a strong random string." |
| ) |
| raise RuntimeError( |
| "BETA_INVITE_CODE must not be empty when BETA_ACCESS_ENABLED=true. " |
| "Set BETA_INVITE_CODE in your environment secrets." |
| ) |
| else: |
| logger.warning( |
| "CONFIG WARNING: BETA_ACCESS_ENABLED=true but BETA_INVITE_CODE is empty. " |
| "All signup attempts will be rejected. Set BETA_INVITE_CODE." |
| ) |
|
|
| |
| |
| |
| if is_prod and (current_settings.storage_provider or "local").strip().lower() == "local": |
| logger.warning( |
| "CONFIG WARNING: STORAGE_PROVIDER=local in production. Generated videos " |
| "and audio will be served from local disk and will be lost on redeploy. " |
| "See backend/STORAGE_SETUP.md to configure Cloudflare R2 or S3." |
| ) |
|
|
| |
| cloud_provider = (current_settings.storage_provider or "").strip().lower() |
| if is_prod and cloud_provider in {"r2", "s3"}: |
| missing_storage_vars = [ |
| name for name, value in [ |
| ("STORAGE_BUCKET", current_settings.storage_bucket), |
| ("STORAGE_ACCESS_KEY_ID", current_settings.storage_access_key_id), |
| ("STORAGE_SECRET_ACCESS_KEY", current_settings.storage_secret_access_key), |
| ] |
| if not value |
| ] |
| if cloud_provider == "r2" and not current_settings.storage_endpoint_url: |
| missing_storage_vars.append("STORAGE_ENDPOINT_URL (required for R2)") |
| if missing_storage_vars: |
| logger.error( |
| "CONFIG ERROR: STORAGE_PROVIDER=%s but missing required vars: %s. " |
| "Cloud video URLs will fail to upload. See backend/STORAGE_SETUP.md.", |
| cloud_provider, |
| ", ".join(missing_storage_vars), |
| ) |
| raise RuntimeError( |
| f"STORAGE_PROVIDER={cloud_provider} requires: " |
| f"{', '.join(missing_storage_vars)}. " |
| "Set these in your environment secrets." |
| ) |
|
|
|
|
| logger = logging.getLogger("docdoe.backend") |
|
|
|
|
| _is_prod = settings.environment == "production" |
| app = FastAPI( |
| title=settings.app_name, |
| version="0.1.0", |
| description="Backend foundation for the AI Exam Success Platform.", |
| lifespan=lifespan, |
| |
| |
| |
| docs_url=None if _is_prod else "/docs", |
| redoc_url=None if _is_prod else "/redoc", |
| openapi_url=None if _is_prod else "/openapi.json", |
| ) |
|
|
|
|
| @app.get("/", include_in_schema=False) |
| def root_status() -> dict[str, str]: |
| return { |
| "status": "ok", |
| "service": settings.app_name, |
| "environment": settings.environment, |
| "health": "/health", |
| "ai_health": "/health/ai", |
| } |
|
|
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=settings.cors_origin_list, |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| @app.middleware("http") |
| async def request_id_middleware(request: Request, call_next): |
| from app.utils.request_id import request_id_ctx_var, sanitize_request_id |
| raw_request_id = request.headers.get("X-Request-ID") |
| request_id = sanitize_request_id(raw_request_id) |
| |
| token = request_id_ctx_var.set(request_id) |
| try: |
| response = await call_next(request) |
| finally: |
| request_id_ctx_var.reset(token) |
| |
| response.headers["X-Request-ID"] = request_id |
| return response |
|
|
|
|
| @app.middleware("http") |
| async def security_headers_middleware(request: Request, call_next): |
| """Add security headers to every response.""" |
| response = await call_next(request) |
| response.headers["X-Content-Type-Options"] = "nosniff" |
| response.headers["X-Frame-Options"] = "DENY" |
| response.headers["X-XSS-Protection"] = "1; mode=block" |
| response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" |
| response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" |
| if settings.environment == "production": |
| response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload" |
| return response |
|
|
|
|
| def _ip_rate_limit_key(request: Request) -> str: |
| ip = request.client.host if request.client else "unknown" |
| return f"ip:{ip}" |
|
|
|
|
| def _verified_jwt_subject(token: str) -> str | None: |
| try: |
| return get_verified_auth_subject(token) |
| except Exception: |
| return None |
|
|
|
|
| def _rate_limit_key(request: Request) -> str: |
| """Return a stable identity key for rate limiting. |
| |
| Prefers the JWT ``sub`` claim so the limit is per-user regardless of IP. |
| Falls back to the client IP when no valid Bearer token is present. |
| """ |
| auth_header = request.headers.get("Authorization", "") |
| if auth_header.startswith("Bearer "): |
| sub = _verified_jwt_subject(auth_header[7:]) |
| if sub: |
| return f"user:{sub}" |
| return _ip_rate_limit_key(request) |
|
|
|
|
| def _should_rate_limit_request(method: str, path: str) -> bool: |
| if method == "OPTIONS": |
| return False |
| if method not in {"POST", "PUT", "PATCH"}: |
| return False |
| return any(path == prefix or path.startswith(prefix) for prefix in _RATE_LIMIT_PREFIXES) |
|
|
|
|
| def _cors_headers_for_early_response(request: Request) -> dict[str, str]: |
| """Mirror allowed CORS origins for middleware responses returned early. |
| |
| Rate-limit responses can return before Starlette's CORSMiddleware gets a |
| chance to decorate the response. Without these headers, browser clients see |
| a generic network/CORS failure instead of the useful 429 body. |
| """ |
| origin = request.headers.get("Origin") |
| if not origin: |
| return {} |
|
|
| allowed_origins = settings.cors_origin_list |
| if "*" not in allowed_origins and origin not in allowed_origins: |
| return {} |
|
|
| return { |
| "Access-Control-Allow-Origin": origin, |
| "Access-Control-Allow-Credentials": "true", |
| "Vary": "Origin", |
| } |
|
|
|
|
| def _rate_limit_response_headers(request: Request, retry_after: int) -> dict[str, str]: |
| headers = {"Retry-After": str(retry_after)} |
| headers.update(_cors_headers_for_early_response(request)) |
| return headers |
|
|
|
|
| @app.middleware("http") |
| async def rate_limit_middleware(request: Request, call_next): |
| """Per-user daily AI request limit on compute-heavy endpoints. |
| |
| Activated only when ``RATE_LIMIT_ENABLED=true``. Returns 429 with a |
| student-safe message once the daily quota is exhausted. |
| """ |
| if not settings.rate_limit_enabled: |
| return await call_next(request) |
|
|
| path = request.url.path |
| if not _should_rate_limit_request(request.method, path): |
| return await call_next(request) |
|
|
| key = _rate_limit_key(request) |
| now = time.time() |
|
|
| |
| |
| |
| |
| |
| if rate_limiter.over_per_minute(key, now, path=path): |
| retry_after = max(1, 60 - int(now % 60)) |
| return JSONResponse( |
| status_code=429, |
| headers=_rate_limit_response_headers(request, retry_after), |
| content={ |
| "success": False, |
| "error": { |
| "code": "RATE_LIMIT_EXCEEDED", |
| "message": "You're moving fast. I'll be ready in a few seconds.", |
| "details": {"retry_after": retry_after, "scope": "minute"}, |
| }, |
| }, |
| ) |
|
|
| |
| |
| |
| if rate_limiter.over_daily(key, settings.rate_limit_ai_requests_per_day, now): |
| retry_after = 86_400 - int(now % 86_400) |
| body = { |
| "code": "RATE_LIMIT_EXCEEDED", |
| "message": "You've reached today's beta limit. Try again tomorrow.", |
| "details": {"retry_after": retry_after, "scope": "daily"}, |
| } |
| return JSONResponse( |
| status_code=429, |
| headers=_rate_limit_response_headers(request, retry_after), |
| content={"success": False, "error": body, "detail": body}, |
| ) |
|
|
| return await call_next(request) |
|
|
|
|
| @app.middleware("http") |
| async def credentialed_cors_preflight(request: Request, call_next): |
| """Answer OPTIONS with Allow-Credentials so browser signup is not blocked. |
| |
| Hugging Face and some Starlette paths return an empty credentials header on |
| preflight. Chrome then rejects fetch({ credentials: 'include' }) from |
| docdoe.in to the Space, and the student only sees 'could not create account'. |
| """ |
| if request.method == "OPTIONS": |
| origin = request.headers.get("Origin", "") |
| if origin in settings.cors_origin_list: |
| requested = request.headers.get( |
| "Access-Control-Request-Headers", |
| "authorization,content-type", |
| ) |
| return Response( |
| status_code=200, |
| headers={ |
| "Access-Control-Allow-Origin": origin, |
| "Access-Control-Allow-Credentials": "true", |
| "Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", |
| "Access-Control-Allow-Headers": requested, |
| "Access-Control-Max-Age": "600", |
| "Vary": "Origin", |
| }, |
| ) |
| return await call_next(request) |
|
|
|
|
| STATUS_CODE_MAP = { |
| 400: "BAD_REQUEST", |
| 401: "UNAUTHORIZED", |
| 402: "PAYMENT_REQUIRED", |
| 403: "FORBIDDEN", |
| 404: "NOT_FOUND", |
| 405: "METHOD_NOT_ALLOWED", |
| 408: "REQUEST_TIMEOUT", |
| 409: "CONFLICT", |
| 422: "UNPROCESSABLE_ENTITY", |
| 429: "RATE_LIMIT_EXCEEDED", |
| 500: "INTERNAL_ERROR", |
| 502: "BAD_GATEWAY", |
| 503: "SERVICE_UNAVAILABLE", |
| 504: "GATEWAY_TIMEOUT", |
| } |
|
|
|
|
| @app.exception_handler(StarletteHTTPException) |
| async def http_exception_handler( |
| request: Request, |
| exc: StarletteHTTPException, |
| ) -> JSONResponse: |
| code = "HTTP_ERROR" |
| message = "An error occurred." |
| details = {} |
|
|
| if isinstance(exc.detail, dict): |
| code = exc.detail.get("code", "HTTP_ERROR") |
| message = exc.detail.get("message", exc.detail.get("detail", "An error occurred.")) |
| details = {k: v for k, v in exc.detail.items() if k not in ("code", "message")} |
| elif isinstance(exc.detail, str): |
| |
| message = sanitize_error_detail(exc.detail) |
| code = STATUS_CODE_MAP.get(exc.status_code, "HTTP_ERROR") |
| else: |
| message = "An error occurred." |
|
|
| error_envelope = { |
| "code": code, |
| "message": message, |
| "details": details, |
| } |
| |
| |
| if isinstance(exc.detail, dict): |
| compat_detail: dict | str = {"code": code, "message": message} |
| elif isinstance(exc.detail, str): |
| compat_detail = sanitize_error_detail(exc.detail) |
| else: |
| compat_detail = message |
| content: dict = { |
| "success": False, |
| "error": error_envelope, |
| "detail": compat_detail, |
| } |
|
|
| headers = getattr(exc, "headers", None) |
| return JSONResponse( |
| status_code=exc.status_code, |
| content=content, |
| headers=headers, |
| ) |
|
|
|
|
| @app.exception_handler(RequestValidationError) |
| async def validation_exception_handler( |
| request: Request, |
| exc: RequestValidationError, |
| ) -> JSONResponse: |
| |
| |
| error_fields = [ |
| {"field": ".".join(str(loc) for loc in err.get("loc", [])), "msg": err.get("msg", "")} |
| for err in exc.errors() |
| ] |
| content = { |
| "success": False, |
| "error": { |
| "code": "VALIDATION_ERROR", |
| "message": "Input validation failed. Please check the request and try again.", |
| "details": { |
| "fields": error_fields, |
| }, |
| }, |
| |
| } |
| return JSONResponse( |
| status_code=422, |
| content=content, |
| ) |
|
|
|
|
| @app.exception_handler(AIProviderError) |
| async def ai_provider_error_handler( |
| _: Request, |
| exc: AIProviderError, |
| ) -> JSONResponse: |
| logger.warning("AI provider error: %s", exc) |
| content = { |
| "success": False, |
| "error": { |
| "code": "GENERATION_FAILED", |
| "message": "DocDoe could not generate this right now.", |
| "details": {}, |
| }, |
| "detail": { |
| "code": "GENERATION_FAILED", |
| "message": "DocDoe could not generate this right now.", |
| }, |
| } |
| return JSONResponse( |
| status_code=502, |
| content=content, |
| ) |
|
|
|
|
| @app.exception_handler(Exception) |
| async def unhandled_exception_handler( |
| request: Request, |
| exc: Exception, |
| ) -> JSONResponse: |
| logger.exception("Unhandled error on %s %s", request.method, request.url.path) |
| content = { |
| "success": False, |
| "error": { |
| "code": "INTERNAL_ERROR", |
| "message": "DocDoe hit an internal error. Please try again.", |
| "details": {}, |
| }, |
| "detail": { |
| "code": "INTERNAL_ERROR", |
| "message": "DocDoe hit an internal error. Please try again.", |
| }, |
| } |
| return JSONResponse( |
| status_code=500, |
| content=content, |
| ) |
|
|
| app.include_router(health.router) |
| app.include_router(telemetry.router, tags=["Telemetry"]) |
| app.include_router(sync.router, tags=["Sync"]) |
| app.include_router(jobs.router, prefix="/jobs", tags=["Jobs"]) |
| app.include_router(dev.router, prefix="/dev", tags=["Dev"]) |
| app.include_router(admin.router, prefix="/admin", tags=["Admin"]) |
| app.include_router(auth.router, prefix="/auth", tags=["Auth"]) |
| app.include_router(users.router, prefix="/users", tags=["Users"]) |
| app.include_router(documents.router, prefix="/documents", tags=["Documents"]) |
| app.include_router(generations.router, prefix="/generations", tags=["Generations (legacy)"]) |
| app.include_router(quizzes.router, prefix="/quizzes", tags=["Quizzes (legacy)"]) |
| app.include_router(flashcards.router, prefix="/flashcards", tags=["Flashcards (legacy)"]) |
| app.include_router( |
| previous_papers.router, |
| prefix="/previous-papers", |
| tags=["Previous Papers"], |
| ) |
| app.include_router(video.router, prefix="/video", tags=["Video Render"]) |
| app.include_router( |
| physics_video.router, |
| prefix="/video/physics-curriculum", |
| tags=["Physics Video Curriculum"], |
| ) |
| app.include_router( |
| chemistry_video.router, |
| prefix="/video/chemistry-curriculum", |
| tags=["Chemistry Video Curriculum"], |
| ) |
| app.include_router( |
| social_science_video.router, |
| prefix="/video/social-science-curriculum", |
| tags=["Social Science Video Curriculum"], |
| ) |
| app.include_router(study_profile.router, prefix="/study-profile", tags=["Study Profile"]) |
| app.include_router(learning_state.router, prefix="/learning-state", tags=["Learning State"]) |
| app.include_router( |
| student_workspace.router, |
| prefix="/student-workspace", |
| tags=["Student Workspace"], |
| ) |
| app.include_router(tuition_profile.router, prefix="/tuition-profile", tags=["Tuition Profile"]) |
| app.include_router(tuition_brain.router, prefix="/tuition-brain", tags=["Tuition Brain"]) |
| app.include_router(transcription.router, prefix="/record-lecture", tags=["Record Lecture"]) |
| app.include_router( |
| class_session_progress.router, prefix="/class-session-progress", tags=["Class Session Progress"] |
| ) |
| app.include_router(phase3_activity.router, prefix="/phase3-activity", tags=["Phase3 Activity"]) |
| app.include_router( |
| learn_anything_roadmap.router, prefix="/learn-anything-roadmaps", tags=["Learn Anything Roadmaps"] |
| ) |
| app.include_router(support.router, prefix="/support", tags=["Support"]) |
| app.include_router(study.router, prefix="/study", tags=["Study Analysis"]) |
| app.include_router(sources.router, prefix="/sources", tags=["Sources"]) |
| app.include_router(study_path.router, prefix="/study-path", tags=["Study Path"]) |
| app.include_router(ask.router, tags=["Ask DocDoe"]) |
| app.include_router(chat.router, tags=["Chat"]) |
| app.include_router(chat_history.router, prefix="/chat", tags=["Chat History"]) |
| app.include_router(generate_studio.router, prefix="/generate", tags=["Studio"]) |
| app.include_router(learning_engine.router, prefix="/generate", tags=["Learning Engine"]) |
| app.include_router(pyq.router, prefix="/pyq", tags=["PYQ"]) |
| app.include_router(pyq_discovery.router, prefix="/pyq-discovery", tags=["PYQ Discovery"]) |
| app.include_router(video_generator.router, prefix="/video-generator", tags=["Video Planning"]) |
| app.include_router(billing.router, prefix="/billing", tags=["Billing"]) |
| app.include_router(intelligence.router, prefix="/intelligence", tags=["Intelligence"]) |
| app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"]) |
| app.include_router(generated_media.router, prefix="/generated", tags=["Generated media"]) |
|
|