import asyncio import base64 import html import io import json import os import re import shutil import tempfile import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Optional import httpx from dotenv import load_dotenv from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile load_dotenv() from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, Response from extractor import extract_page_range app = FastAPI(title="Problem Extractor") OUTPUT_DIR = Path("output") OUTPUT_DIR.mkdir(exist_ok=True) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) SAVED_PDF = OUTPUT_DIR / "saved_textbook.pdf" SAVED_PDF_NAME = OUTPUT_DIR / "saved_textbook_name.txt" BUNDLED_PDF = (Path(__file__).parent / "../../Reference and Solution Manual/Modern Control Systems-12 Edition.pdf").resolve() BUNDLED_PDF_NAME = "Modern Control Systems-12 Edition.pdf" EDUVERSE_API_URL = os.getenv("EDUVERSE_API_URL", "https://eduverse-team-eduverse-backend.hf.space") EDUVERSE_EMAIL = os.getenv("EDUVERSE_EMAIL", "") EDUVERSE_PASSWORD = os.getenv("EDUVERSE_PASSWORD", "") _eduverse_token: Optional[str] = None def _active_pdf() -> Optional[Path]: """Return the PDF to use: user-uploaded first, then the bundled textbook.""" if SAVED_PDF.exists(): return SAVED_PDF if BUNDLED_PDF.exists(): return BUNDLED_PDF return None async def _get_eduverse_token() -> str: global _eduverse_token if _eduverse_token: return _eduverse_token if not EDUVERSE_EMAIL or not EDUVERSE_PASSWORD: raise HTTPException(status_code=500, detail="EDUVERSE_EMAIL and EDUVERSE_PASSWORD must be set in .env") async with httpx.AsyncClient() as client: r = await client.post( f"{EDUVERSE_API_URL}/api/auth/login", json={"email": EDUVERSE_EMAIL, "password": EDUVERSE_PASSWORD}, timeout=15, ) if r.status_code != 200: raise HTTPException(status_code=502, detail=f"EduVerse login failed: {r.text}") data = r.json() token = data.get("accessToken") or data.get("access_token") or data.get("token") if not token: raise HTTPException(status_code=502, detail=f"EduVerse login response missing token: {data}") _eduverse_token = token return token GEMINI_PROMPT = """\ You are extracting problems from an academic textbook. The text was extracted via OCR from a scanned PDF and may contain artifacts, merged columns, and garbled spacing. Identify every distinct problem or question. Return a JSON array where each object has exactly these fields: - "question_number": string — the problem label as it appears (e.g. "P1.1", "Q3", "Exercise 4.2", "Problem 7") - "question": string — complete question text, cleaned of OCR errors. Remove figure captions and page headers. Fix broken words from column wrapping. Format all math using LaTeX: inline with $...$ (e.g. $x_1$, $\\omega_n$, $K > 0$), display with $$...$$. - "page": number — page where the problem starts (use the --- PAGE N --- markers) - "question_type": string — one of exactly: "written", "mcq", "true_false". Use "mcq" only if the question lists labeled answer choices (A/B/C/D or similar). Use "true_false" only if the question is a direct true-or-false statement. Use "written" for everything else (derivations, calculations, short answer, design problems). - "difficulty": string — one of exactly: "easy", "medium", "hard". Base this on cognitive load and prerequisite knowledge: easy = recall or single-step, medium = multi-step application, hard = synthesis, design, or proof. - "bloom_level": string — one of exactly: "remembering", "understanding", "applying", "analyzing", "evaluating", "creating". Pick the highest Bloom's taxonomy level the question primarily demands. - "has_diagram": boolean — true if the question references a Figure, asks to sketch a diagram or block diagram, or involves a circuit - "figure_reference": string or null — specific figure label when present (e.g. "Figure 13.18", "Fig. P1.2"), otherwise null - "exam_ready": boolean — true if suitable for a university exam. Mark false for: pure discussion/describe questions, problems entirely dependent on a figure students won't have, or questions too open-ended to grade objectively. - "exam_notes": string or null — if exam_ready is false, a brief reason (e.g. "discussion only", "requires Figure P1.2", "open-ended design"). If exam_ready is true, use null. - "expected_answer": string or null — for "written" and "true_false" question types only: a concise model answer (1–4 sentences or key steps). For "mcq" use null (the correct option already encodes the answer). - "hints": string or null — a short hint (1–2 sentences) that nudges a student toward the solution without giving it away. Provide for all question types. Use null if no meaningful hint can be given. Return ONLY a valid JSON array wrapped in ```json fences. No explanation. --- TEXT START --- """ _HTML = """ Problem Extractor

Problem Extractor

Latest saved

Extract text → Gemini structures problems → review & export.

| Pages
1 Copy & paste into gemini.google.com
Copies the full prompt + your extracted text in one click.
2 Paste Gemini’s JSON response
Extracting pages and gathering text...
Save to EduVerse
Image preview
""" HTML = _HTML.replace("__PROMPT__", json.dumps(GEMINI_PROMPT)) @app.get("/health") def health(): return {"ok": True} @app.get("/", response_class=HTMLResponse) def home(): return HTML @app.get("/api/pdf-info") def pdf_info(): if SAVED_PDF.exists(): name = SAVED_PDF_NAME.read_text(encoding="utf-8") if SAVED_PDF_NAME.exists() else "textbook.pdf" return {"saved": True, "name": name} if BUNDLED_PDF.exists(): return {"saved": True, "name": BUNDLED_PDF_NAME} return {"saved": False, "name": None} @app.post("/api/extract") async def extract( textbook: Optional[UploadFile] = File(None), tb_start: int = Form(...), tb_end: int = Form(...), ): if textbook and textbook.filename: with open(SAVED_PDF, "wb") as f: shutil.copyfileobj(textbook.file, f) SAVED_PDF_NAME.write_text(textbook.filename, encoding="utf-8") active = _active_pdf() if active is None: raise HTTPException(status_code=400, detail="No PDF available. Please choose a PDF file first.") for old in OUTPUT_DIR.glob("q_page*"): old.unlink(missing_ok=True) text, images = await asyncio.to_thread( extract_page_range, str(active), tb_start, tb_end, OUTPUT_DIR, "q" ) with open(OUTPUT_DIR / "raw_text.txt", "w", encoding="utf-8") as f: f.write(text) return {"text": text, "images": images, "char_count": len(text)} # LaTeX commands that start with b/f/n/r/t after a single "\". JSON treats \b \f \n \r \t as # control characters, so "\frac" becomes form feed + "rac" and "\to" becomes tab + "o" unless # we double the backslash before json.loads. Longer tokens first so e.g. \rightarrow is not split as \right. _LATEX_JSON_COLLISION_TOKENS = sorted( { "boldsymbol", "textbf", "textit", "textsf", "texttt", "textnormal", "text", "tfrac", "theta", "Theta", "triangle", "tilde", "times", "tanh", "tan", "tau", "to", "Rightarrow", "Rrightarrow", "rightarrow", "rightleftharpoons", "right", "rho", "rangle", "rfloor", "mathrm", "mathcal", "mathit", "mathfrak", "mathtt", "mathbf", "binom", "begin", "beta", "biggl", "biggr", "bigl", "bigr", "big", "nabla", "notin", "neq", "nu", "frac", "fbox", }, key=len, reverse=True, ) def _fix_latex_json_escape_collisions(raw: str) -> str: out = raw for tok in _LATEX_JSON_COLLISION_TOKENS: pat = rf"(? Optional[int]: m = re.match(r"^[A-Za-z]+(\d+)\.", (question_number or "").strip()) return int(m.group(1)) if m else None @app.post("/api/export-chapter") async def export_chapter(payload: dict): chapter = payload.get("chapter") problems = payload.get("problems") teammate = (payload.get("teammate") or "unknown").strip() source_book = (payload.get("source_book") or "Unknown Book").strip() if not isinstance(chapter, int) or chapter < 1: raise HTTPException(status_code=400, detail="Invalid chapter number.") if not isinstance(problems, list) or not problems: raise HTTPException(status_code=400, detail="No problems provided for export.") chapter_questions = [ q for q in problems if isinstance(q, dict) and _chapter_from_question_number(str(q.get("question_number", ""))) == chapter ] if not chapter_questions: raise HTTPException(status_code=400, detail=f"No questions found for chapter {chapter}.") export_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ") teammate_slug = re.sub(r"[^a-z0-9]+", "-", teammate.lower()).strip("-") or "unknown" package_slug = f"chapter-{chapter:02d}-{teammate_slug}-{export_ts}" zip_buffer = io.BytesIO() image_counter = 0 prepared_questions = [] manifest = { "package_version": 1, "package_slug": package_slug, "exported_at_utc": datetime.now(timezone.utc).isoformat(), "source_book": source_book, "chapter": chapter, "teammate": teammate, "question_count": len(chapter_questions), } with zipfile.ZipFile(zip_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf: for q in chapter_questions: q_num = str(q.get("question_number", "")).strip() or f"Q{len(prepared_questions) + 1}" q_slug = re.sub(r"[^A-Za-z0-9._-]+", "_", q_num) image_paths = [] for img in q.get("images", []) or []: if not isinstance(img, dict): continue data_url = str(img.get("data", "")) if not data_url.startswith("data:image/") or "," not in data_url: continue header, b64 = data_url.split(",", 1) ext_match = re.search(r"data:image/([a-zA-Z0-9+.-]+);base64", header) ext = (ext_match.group(1) if ext_match else "png").replace("jpeg", "jpg") image_counter += 1 img_path = f"{package_slug}/images/{q_slug}_{image_counter:02d}.{ext}" try: zf.writestr(img_path, base64.b64decode(b64)) image_paths.append(img_path.replace(f"{package_slug}/", "")) except Exception: continue prepared_questions.append({ "source_book": source_book, "chapter": chapter, "question_number": q_num, "question_type": re.match(r"^([A-Za-z]+)", q_num).group(1).upper() if re.match(r"^([A-Za-z]+)", q_num) else None, "question_text": str(q.get("question", "")), "page": q.get("page"), "has_diagram": bool(q.get("has_diagram")), "figure_reference": q.get("figure_reference"), "exam_ready": q.get("exam_ready") is not False, "exam_notes": q.get("exam_notes"), "expected_answer": q.get("expected_answer"), "assets": image_paths, "source": { "teammate": teammate, "exported_at_utc": datetime.now(timezone.utc).isoformat(), }, }) zf.writestr( f"{package_slug}/questions.json", json.dumps(prepared_questions, indent=2, ensure_ascii=False), ) manifest["asset_count"] = image_counter zf.writestr( f"{package_slug}/manifest.json", json.dumps(manifest, indent=2, ensure_ascii=False), ) zip_bytes = zip_buffer.getvalue() filename = f"{package_slug}.zip" headers = {"Content-Disposition": f'attachment; filename="{filename}"'} return Response(content=zip_bytes, media_type="application/zip", headers=headers) @app.post("/api/save") async def save(problems: list): with open(OUTPUT_DIR / "problems.json", "w", encoding="utf-8") as f: json.dump(problems, f, indent=2, ensure_ascii=False) return {"ok": True} _BLOOM_MAP = { "remembering": "remembering", "understanding": "understanding", "applying": "applying", "analyzing": "analyzing", "evaluating": "evaluating", "creating": "creating", } _DIFFICULTY_MAP = {"easy": "easy", "medium": "medium", "hard": "hard"} _TYPE_MAP = {"written": "written", "mcq": "mcq", "true_false": "true_false", "essay": "essay"} def _to_backend_question(q: dict, course_id: int, chapter_id: int, question_file_id: int | None = None) -> dict: question_type = _TYPE_MAP.get(str(q.get("question_type", "")).lower(), "written") difficulty = _DIFFICULTY_MAP.get(str(q.get("difficulty", "")).lower(), "medium") bloom_level = _BLOOM_MAP.get(str(q.get("bloom_level", "")).lower(), "applying") payload: dict = { "courseId": course_id, "chapterId": chapter_id, "questionType": question_type, "difficulty": difficulty, "bloomLevel": bloom_level, "questionText": str(q.get("question", "") or ""), "status": "draft", } if question_file_id: payload["questionFileId"] = question_file_id if question_type in ("written", "essay"): payload["expectedAnswerText"] = str(q.get("expected_answer", "") or "") if q.get("hints"): payload["hints"] = str(q["hints"]) if question_type == "mcq" and q.get("options"): payload["options"] = [ {"optionText": str(o.get("optionText", o) if isinstance(o, dict) else o), "isCorrect": bool(o.get("isCorrect", False)) if isinstance(o, dict) else False} for o in q["options"] ] elif question_type == "true_false": payload["options"] = [ {"optionText": "True", "isCorrect": True}, {"optionText": "False", "isCorrect": False}, ] return payload async def _upload_question_image(client: httpx.AsyncClient, token: str, img_b64: str, filename: str) -> int | None: """Upload a base64 image to EduVerse, return fileId or None on failure.""" try: header, data = img_b64.split(",", 1) mime = header.split(";")[0].split(":")[1] img_bytes = base64.b64decode(data) except Exception: return None async def _do_upload(tok: str): return await client.post( f"{EDUVERSE_API_URL}/api/question-bank/questions/upload-image", headers={"Authorization": f"Bearer {tok}"}, files={"image": (filename, img_bytes, mime)}, ) r = await _do_upload(token) if r.status_code == 401: global _eduverse_token _eduverse_token = None token = await _get_eduverse_token() r = await _do_upload(token) if not r.is_success: return None resp = r.json() file_id = resp.get("fileId") or resp.get("data", {}).get("fileId") return int(file_id) if file_id else None @app.post("/api/save-to-eduverse") async def save_to_eduverse(payload: dict): """ Bulk-save reviewed problems to the EduVerse question bank. Expects: { courseId, chapterId, problems: [...] } Images (base64 data URLs) in each problem's `images` array are uploaded first; the returned fileId is set as questionFileId on the question. """ global _eduverse_token course_id = payload.get("courseId") chapter_id = payload.get("chapterId") problems = payload.get("problems", []) if not isinstance(course_id, int) or course_id < 1: raise HTTPException(status_code=400, detail="courseId must be a positive integer") if not isinstance(chapter_id, int) or chapter_id < 1: raise HTTPException(status_code=400, detail="chapterId must be a positive integer") if not problems: raise HTTPException(status_code=400, detail="No problems provided") token = await _get_eduverse_token() images_uploaded = 0 async with httpx.AsyncClient(timeout=60) as client: # Step 1: upload primary image for each problem that has one question_file_ids: list[int | None] = [] for q in problems: imgs = q.get("images") or [] if imgs: first = imgs[0] file_id = await _upload_question_image(client, token, first.get("data", ""), first.get("name", "diagram.png")) question_file_ids.append(file_id) if file_id: images_uploaded += 1 else: question_file_ids.append(None) # Step 2: batch-create questions with questionFileId already set questions = [ _to_backend_question(q, course_id, chapter_id, question_file_ids[i]) for i, q in enumerate(problems) ] BATCH_SIZE = 50 all_created, all_failed = [], [] for i in range(0, len(questions), BATCH_SIZE): batch = questions[i:i + BATCH_SIZE] r = await client.post( f"{EDUVERSE_API_URL}/api/question-bank/questions/batch", json={"courseId": course_id, "defaultChapterId": chapter_id, "questions": batch}, headers={"Authorization": f"Bearer {token}"}, ) if r.status_code == 401: _eduverse_token = None token = await _get_eduverse_token() r = await client.post( f"{EDUVERSE_API_URL}/api/question-bank/questions/batch", json={"courseId": course_id, "defaultChapterId": chapter_id, "questions": batch}, headers={"Authorization": f"Bearer {token}"}, ) if not r.is_success: raise HTTPException(status_code=502, detail=f"EduVerse batch failed: {r.text}") data = r.json() all_created.extend(data.get("created", [])) all_failed.extend( [{"rowIndex": f["rowIndex"] + i, **{k: v for k, v in f.items() if k != "rowIndex"}} for f in data.get("failed", [])] ) return { "saved": len(all_created), "failed": len(all_failed), "failedItems": all_failed, "imagesUploaded": images_uploaded, } @app.get("/output/{filename}") def serve_output(filename: str): path = OUTPUT_DIR / filename if not path.exists(): raise HTTPException(status_code=404, detail="Not found") return FileResponse(path) @app.get("/latest-questions", response_class=HTMLResponse) async def latest_questions_shell(courseId: int = 30, chapterId: Optional[int] = None, limit: int = 100): """Shell page — loads instantly, then fetches content via AJAX.""" chapter_param = f"&chapterId={chapterId}" if chapterId is not None else "" data_url = f"/latest-questions-data?courseId={courseId}{chapter_param}&limit={limit}" return f""" Latest Questions — course {courseId}

Latest Saved Questions

Back
Fetching questions... Pulling from the question bank and checking for duplicates.
""" @app.get("/latest-questions-data", response_class=HTMLResponse) async def latest_questions_data(courseId: int, chapterId: Optional[int] = None, limit: int = 100): """Data fragment — fetched by the shell page via AJAX.""" from collections import defaultdict from datetime import datetime as _dt, timezone as _tz if limit < 1 or limit > 500: limit = 100 PAGE_SIZE = 100 MAX_PAGES = 20 questions: list[dict] = [] total = 0 token = await _get_eduverse_token() async with httpx.AsyncClient() as client: for page in range(1, MAX_PAGES + 1): params: dict = {"courseId": courseId, "page": page, "limit": PAGE_SIZE} if chapterId is not None: params["chapterId"] = chapterId r = await client.get( f"{EDUVERSE_API_URL}/api/question-bank/questions", params=params, headers={"Authorization": f"Bearer {token}"}, timeout=30, ) if r.status_code == 401: global _eduverse_token _eduverse_token = None token = await _get_eduverse_token() r = await client.get( f"{EDUVERSE_API_URL}/api/question-bank/questions", params=params, headers={"Authorization": f"Bearer {token}"}, timeout=30, ) if r.status_code != 200: raise HTTPException(status_code=502, detail=f"List failed page={page}: {r.text}") body = r.json() if isinstance(body, dict): items = body.get("data") or body.get("items") or body.get("questions") or [] if page == 1: total = int(body.get("total") or len(items)) elif isinstance(body, list): items = body if page == 1: total = len(items) else: items = [] if not items: break questions.extend(items) if len(questions) >= limit or len(items) < PAGE_SIZE: break questions.sort( key=lambda q: (q.get("createdAt") or "", q.get("id") or 0), reverse=True, ) questions = questions[:limit] # Duplicate detection dup_groups: dict[str, list[dict]] = defaultdict(list) for q in questions: text = (q.get("questionText") or "").strip().lower() if text: dup_groups[text].append(q) dups = {k: v for k, v in dup_groups.items() if len(v) > 1} dup_ids: set[int] = {q.get("id") for items in dups.values() for q in items} # Group by save-minute (YYYY-MM-DD HH:MM) def minute_key(q): raw = q.get("createdAt") or "" if not raw: return "~no-date" try: iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw d = _dt.fromisoformat(iso) return d.strftime("%Y-%m-%d %H:%M") except Exception: return "~no-date" batches: dict[str, list[dict]] = defaultdict(list) for q in questions: batches[minute_key(q)].append(q) # Sort batches newest first sorted_batches = sorted(batches.items(), key=lambda kv: kv[0], reverse=True) def fmt_date(s): if not s: return "—" try: iso = s[:-1] + "+00:00" if isinstance(s, str) and s.endswith("Z") else s d = _dt.fromisoformat(iso) if isinstance(iso, str) else iso return d.strftime("%Y-%m-%d %I:%M %p") except Exception: return str(s) def fmt_minute(key): if key == "~no-date": return "Unknown time" try: d = _dt.strptime(key, "%Y-%m-%d %H:%M") return d.strftime("%Y-%m-%d %I:%M %p") + " UTC" except Exception: return key def trunc(s, n=240): s = s or "" return s if len(s) <= n else s[: n - 1] + "…" def img_tag(q): url = q.get("questionImageUrl") if not url: return '' esc = html.escape(url) return f'figure' def type_badge(v): v = str(v or "") return f'{html.escape(v)}' if v else '' def diff_badge(v): v = str(v or "") c = {"easy": "badge-green", "medium": "badge-blue", "hard": "badge-red"}.get(v.lower(), "badge-gray") return f'{html.escape(v)}' if v else '' def status_badge(v): v = str(v or "") c = {"approved": "badge-green", "draft": "badge-gray", "rejected": "badge-red"}.get(v.lower(), "badge-gray") return f'{html.escape(v)}' if v else '' # Build batch HTML batches_html_parts = [] for key, qs in sorted_batches: def _card(q): is_dup = q.get("id") in dup_ids has_ans = bool((q.get("expectedAnswerText") or "").strip()) has_hint = bool((q.get("hints") or "").strip()) card_cls = "q-card dup-card-q" if is_dup else "q-card" dup_marker = ' duplicate' if is_dup else "" qtext = q.get("questionText") or "" ans_text = html.escape(q.get("expectedAnswerText") or "") hint_text = html.escape(q.get("hints") or "") rid = q.get("id") saved_fmt = html.escape(fmt_date(q.get("createdAt"))) img_url = q.get("questionImageUrl") # Image block if img_url: esc_url = html.escape(img_url) img_block = f'
figure
' else: img_block = "" # Answer + hint if has_ans: ans_block = f'
Answer
{ans_text}
' else: ans_block = "" if has_hint: hint_block = f'
Hint
{hint_text}
' else: hint_block = "" body_layout = "card-body-split" if img_url else "card-body-single" return ( f'
' # Top bar f'
' f' #{rid}' f' {type_badge(q.get("questionType"))}' f' {diff_badge(q.get("difficulty"))}' f' {html.escape(str(q.get("bloomLevel") or ""))}' f' {status_badge(q.get("status"))}' f' {dup_marker}' f' {saved_fmt}' f'
' # Body f'
' f'
{html.escape(qtext)}
' f' {img_block}' f'
' # Answer / hint f'{ans_block}' f'{hint_block}' f'
' ) q_cards = "".join(_card(q) for q in qs) has_dups = any(q.get("id") in dup_ids for q in qs) dup_warn = ' has duplicates' if has_dups else "" batches_html_parts.append(f"""
{html.escape(fmt_minute(key))} {len(qs)} question{'s' if len(qs) != 1 else ''} {dup_warn}
{q_cards}
""") batches_html = "".join(batches_html_parts) # Duplicate cards dup_html = "" if dups: parts = [] for text, items in sorted(dups.items(), key=lambda kv: -len(kv[1])): sample = items[0].get("questionText") or "" rows = "".join( f'
  • id={q.get("id")} · {html.escape(fmt_date(q.get("createdAt")))} · {html.escape(str(q.get("questionType","")))} · ch={q.get("chapterId")}
  • ' for q in items ) parts.append( f'
    ' f'
     {len(items)} copies
    ' f'
    {html.escape(sample)}
    ' f'
    ' ) dup_html = "".join(parts) else: dup_html = '

    No exact duplicates found.

    ' chapter_label = f"chapter {chapterId}" if chapterId is not None else "all chapters" empty = "" if questions else f'

    No questions found for course {courseId} ({chapter_label}).

    ' generated = _dt.now(_tz.utc).strftime("%Y-%m-%d %I:%M %p UTC") # Distinct values for filter chips def _vals(key): return sorted({str(q.get(key) or "").strip() for q in questions if (q.get(key) or "").strip()}) types_vals = _vals("questionType") diff_vals = _vals("difficulty") bloom_vals = _vals("bloomLevel") status_vals = _vals("status") def chip_row(group_id, values, label): chips = "".join( f'{html.escape(v)}' for v in values ) return ( f'{html.escape(label)}' f'{chips}' f'' ) if values else "" return f"""

    Course {courseId} — {chapter_label} — {len(questions)} of {total} questions — generated {generated}

    {len(questions)}Showing
    {total}Total
    {len(dups)}Dup groups
    {sum(len(v) for v in dups.values())}Dup rows
    {empty}
    {chip_row("type", types_vals, "Type")} {chip_row("difficulty", diff_vals, "Difficulty")} {chip_row("bloom", bloom_vals, "Bloom")} {chip_row("status", status_vals, "Status")} Has answer No answer Duplicates only

    Duplicates (exact text)

    {dup_html}

    Save batches (grouped by minute)

    {batches_html} """ if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=int(os.getenv("PORT", 8001)), reload=True)