Spaces:
Running on Zero
Running on Zero
| """Agenda Parser β a Gradio app (Server mode) with a React frontend. | |
| This is a real Gradio app: it uses :class:`gradio.Server` ("gr.server"), Gradio's | |
| API engine, and is started with Gradio's own ``.launch()`` β so it deploys as a | |
| Hugging Face *Gradio* Space. The only twist is the frontend: instead of Gradio's | |
| auto-generated UI we serve a custom **React + Tailwind** single-page app and let | |
| it call the Gradio API endpoints. | |
| How it fits together | |
| -------------------- | |
| * ``gradio.Server`` *is* a FastAPI app with an extra ``.api()`` decorator that | |
| registers an endpoint with Gradio's queue + SSE streaming. We register the | |
| pure functions from ``webapp/backend.py`` as the API surface. | |
| * ``server.launch()`` builds Gradio's internal Blocks for those endpoints and | |
| starts Gradio's server. ``App.create_app(app=server)`` *reuses* this app and | |
| appends Gradio's routes (``/gradio_api/*`` plus its own ``/`` and ``/assets``) | |
| to it. FastAPI matches routes in registration order, so the SPA routes we | |
| register **before** ``launch()`` take precedence over Gradio's default UI at | |
| ``/`` and ``/assets`` β while ``/gradio_api/*`` (which we never touch) keeps | |
| working for the client. | |
| * The React client (``frontend/src/api.ts``) talks to the endpoints with | |
| ``@gradio/client``, which speaks Gradio's protocol β so the streaming | |
| ``summarize`` / ``report`` generators surface live progress in the browser. | |
| Run it:: | |
| python -m webapp.server # http://localhost:7860 (React UI + Gradio API) | |
| API endpoints (Gradio Server mode, addressable by @gradio/client): | |
| ``parse``, ``summarize``, ``report``, ``item_report``, ``item_pages``, ``agent``. | |
| Plus two plain FastAPI routes for the packet PDF itself: ``POST /packet_upload`` | |
| (multipart upload + parse) and ``GET /packet`` (serve the cached PDF by upload_id). | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from pathlib import Path | |
| import gradio as gr | |
| from fastapi import File, Form, UploadFile | |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from urllib.parse import quote | |
| from webapp import backend | |
| HERE = Path(__file__).resolve().parent | |
| DIST = HERE / "frontend" / "dist" | |
| # --------------------------------------------------------------------------- # | |
| # The Gradio app (Server mode): backend functions exposed as API endpoints. | |
| # --------------------------------------------------------------------------- # | |
| server = gr.Server(title="Agenda Parser") | |
| server.api(backend.parse_agenda_outline_from_packet, name="parse") | |
| server.api(backend.list_samples, name="list_samples") | |
| server.api(backend.ingest_sample, name="ingest_sample") | |
| server.api(backend.available_models, name="models") | |
| server.api(backend.summarize_agenda, name="summarize") | |
| server.api(backend.generate_agenda_report, name="report") | |
| server.api(backend.generate_agenda_item_report, name="item_report") | |
| server.api(backend.agenda_item_pages, name="item_pages") | |
| server.api(backend.agent_chat, name="agent") | |
| server.api(backend.lii_agent_chat, name="lii_agent") | |
| # Uploading the Agenda Packet PDF. A plain FastAPI multipart route, NOT a Gradio API | |
| # endpoint: agenda packets are tens of MB, and a `gr.api` `bytes` argument would be | |
| # base64-bloated and held whole in memory over Gradio's SSE/JSON queue. Starlette | |
| # streams the multipart body straight to us. Doubles as the re-upload endpoint: pass | |
| # the existing `upload_id` to rebind a saved agenda to its restored packet. | |
| async def _packet_upload( | |
| file: UploadFile = File(...), | |
| agenda_pages: str = Form(""), | |
| upload_id: str = Form(""), | |
| ): | |
| try: | |
| data = await file.read() | |
| result = backend.upload_packet( | |
| data, file.filename or "agenda-packet.pdf", agenda_pages, | |
| upload_id=upload_id or "", | |
| ) | |
| except Exception as e: # noqa: BLE001 | |
| return JSONResponse( | |
| {"error": f"Could not parse the packet: {type(e).__name__}: {e}"}, | |
| status_code=500, | |
| ) | |
| return JSONResponse(result) | |
| # Serve the uploaded Agenda Packet PDF by upload_id. Not a Gradio API endpoint β a | |
| # plain route so the browser gets the raw PDF (the @gradio/client JSON protocol would | |
| # force a wasteful base64 round-trip for a multi-MB file). | |
| # | |
| # Modes (query params): | |
| # ?prepare=1 -> warm the derived caches (outline + page count); return JSON | |
| # {name, size} with NO PDF body. | |
| # ?inline=1 -> serve with Content-Disposition: inline so window.open() displays | |
| # the PDF in a new tab (default is attachment = save). | |
| # | |
| # A 404 with {code: "no_packet"} means the upload is gone (tmp wiped on a Space | |
| # restart, or LRU-evicted) β the client maps this to its "re-upload" prompt. | |
| def _packet(upload_id: str, prepare: bool = False, inline: bool = False): | |
| result = backend.cached_packet(upload_id) | |
| if result is None: | |
| return JSONResponse( | |
| {"error": "The packet is no longer on the server β re-upload it.", | |
| "code": "no_packet"}, | |
| status_code=404, | |
| ) | |
| path, name = result | |
| if prepare: | |
| # Warm the cheap derived caches too (outline + page count), so when the user | |
| # opens an item's Pages drawer the outline-only slice is instant and the only | |
| # click-time work left is rendering. Both read the already-cached PDF; failures | |
| # are non-fatal (the viewer falls back to the text slice). | |
| try: | |
| backend.cached_packet_outline(upload_id) | |
| backend.cached_packet_page_count(upload_id) | |
| except Exception: # noqa: BLE001 - warming is best-effort | |
| pass | |
| return JSONResponse({"name": name, "size": path.stat().st_size}) | |
| disposition = "inline" if inline else "attachment" | |
| return FileResponse( | |
| str(path), | |
| media_type="application/pdf", | |
| headers={"Content-Disposition": f"{disposition}; filename*=UTF-8''{quote(name)}"}, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # The React frontend. Registered BEFORE launch() so these routes win over | |
| # Gradio's default "/" and "/assets" handlers (registration order). We use | |
| # specific paths only β never a catch-all β so Gradio's "/gradio_api/*" routes, | |
| # added during launch(), still resolve for the API client. | |
| # --------------------------------------------------------------------------- # | |
| if DIST.is_dir(): | |
| # Vite emits hashed bundles under /assets β shadow Gradio's /assets route. | |
| server.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets") | |
| def _spa_index() -> FileResponse: | |
| return FileResponse(DIST / "index.html") | |
| else: | |
| def _placeholder() -> str: # pragma: no cover - dev convenience | |
| return ( | |
| "<h1>Frontend not built</h1>" | |
| "<p>Run <code>npm install && npm run build</code> in " | |
| "<code>webapp/frontend</code>, then restart. The Gradio API is already " | |
| "live under <code>/gradio_api</code>.</p>" | |
| ) | |
| def main() -> None: | |
| # In local-model mode, download every selectable GGUF once at startup so the | |
| # request-time model toggle never stalls on a download (the GPU/inference happens | |
| # per request, reloading the chosen model onto the freshly-allocated GPU). | |
| if os.getenv("LLM_BACKEND", "remote").strip().lower() == "local": | |
| from webapp import local_llm | |
| labels = ", ".join(f"{k} ({m['repo']}/{m['file']})" | |
| for k, m in local_llm.MODELS.items()) | |
| print(f"[startup] prefetching GGUFs: {labels} β¦", flush=True) | |
| local_llm.prefetch_models() | |
| print("[startup] models ready.", flush=True) | |
| # Hugging Face Spaces set these; fall back to sane local defaults. | |
| host = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0") | |
| port = int(os.getenv("GRADIO_SERVER_PORT", "7860")) | |
| server.launch(server_name=host, server_port=port) | |
| if __name__ == "__main__": | |
| main() | |