Spaces:
Running on Zero
Running on Zero
File size: 8,121 Bytes
e94c043 5047d1b 7182ecf e94c043 459bdc8 e94c043 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | """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 (
"<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()
|