"""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. @server.post("/packet_upload") 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. @server.get("/packet") 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") @server.get("/", response_class=HTMLResponse) def _spa_index() -> FileResponse: return FileResponse(DIST / "index.html") else: @server.get("/", response_class=HTMLResponse) def _placeholder() -> str: # pragma: no cover - dev convenience return ( "

Frontend not built

" "

Run npm install && npm run build in " "webapp/frontend, then restart. The Gradio API is already " "live under /gradio_api.

" ) 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()