"""Standard Gradio Blocks UI — the fallback frontend for the ZeroGPU Space. The primary frontend is the React app served by ``webapp/server.py`` (gr.Server Server mode). If that doesn't drive under ZeroGPU's runtime, point the Space's ``app_file`` at this module instead: it's a plain ``gr.Blocks`` UI over the exact same ``webapp/backend.py`` functions, so parse/summarize/report still run in-process on ZeroGPU (when ``LLM_BACKEND=local``). The flow mirrors the React app: upload an agenda-packet PDF + give the agenda page range, parse it into items, then summarize / report on an item / view its pages / ask the agent. Run: ``python -m webapp.app_blocks`` (or set it as the Space app_file). """ from __future__ import annotations import os import gradio as gr from webapp import backend def _item_titles(items: list[dict]) -> list[str]: """Every item's ``" "`` line, in agenda order — the anchors the backend uses to locate and bound each item's section in the packet.""" return [f"{it['number']} {it['name']}".strip() for it in items] def _upload(file_path, agenda_pages): """Parse an uploaded packet into agenda items and populate the item picker.""" blank = gr.update(choices=[], value=None) if not file_path: return "_Upload an agenda-packet PDF to begin._", blank, None try: with open(file_path, "rb") as fh: data = fh.read() name = os.path.basename(file_path) res = backend.upload_packet(data, name, (agenda_pages or "").strip()) except Exception as e: # noqa: BLE001 return f"_Could not parse the packet: {type(e).__name__}: {e}_", blank, None items = res.get("items", []) if not items: return ("_No agenda items were parsed — check the agenda page range._", blank, None) lines, choices = [], [] for i, it in enumerate(items): label = f"{it['number']} {it['name']}".strip() or "(untitled item)" indent = " " * (4 * int(it.get("level", 0))) if it.get("is_section"): lines.append(f"{indent}**{label}**") else: tag = f" · pp. {it['pages']}" if it.get("has_pages") else " · _no pages_" lines.append(f"{indent}{label}{tag}") choices.append((label[:90], i)) md = (f"### Agenda items\n_{len(choices)} reportable item(s) · {res.get('page_count')} " f"packet pages · parsed from the {res.get('source')}_\n\n" + " \n".join(lines)) state = {"upload_id": res["upload_id"], "items": items} return md, gr.update(choices=choices, value=None), state def _view_pages(state, item_idx): if not state or item_idx is None: return [], "_Pick an agenda item, then view its packet pages._" items = state["items"] if item_idx < 0 or item_idx >= len(items): return [], "_That item is no longer available — re-parse the packet._" from webapp import pdf_view it = items[item_idx] try: images, caption = pdf_view.render_item_pages( state["upload_id"], _item_titles(items), item_idx, int(it.get("start") or 0), int(it.get("end") or 0), ) except Exception as e: # noqa: BLE001 return [], f"_Couldn't render the packet pages: {type(e).__name__}: {e}_" return images, caption def _summarize(state, model): if not state: yield "_Upload a packet, then click Summarize._" return head = "## Agenda summary\n\n" for frame in backend.summarize_agenda(state["upload_id"], model): if frame["stage"] == "error": yield head + f"❌ {frame['message']}" elif frame["stage"] == "working": yield head + f"⏳ {frame['message']}" else: meta = f"_Summarized by `{frame['model']}` · {frame['message']}_\n\n" yield head + meta + frame["summary"] def _item_report(state, item_idx, question, thoroughness, engine, model): """Generate a report scoped to the selected agenda item.""" if not state or item_idx is None: yield "_Pick an agenda item, enter a question, then Generate item report._" return items = state["items"] if item_idx < 0 or item_idx >= len(items): yield "_That item is no longer available — re-parse the packet._" return it = items[item_idx] item_label = f"{it['number']} {it['name']}".strip() or "(untitled item)" head = f"## {item_label}\n\n**Question:** {question}\n\n" for frame in backend.generate_agenda_item_report( state["upload_id"], _item_titles(items), item_idx, question, int(it.get("start") or 0), int(it.get("end") or 0), int(thoroughness), engine, model, ): if frame["stage"] == "error": yield head + f"❌ {frame['message']}" elif frame["stage"] == "working": pct = int(frame.get("frac", 0) * 100) yield head + f"⏳ [{pct}%] {frame['message']}" else: meta = f"_Reported by `{frame['model']}` · {frame['message']}_\n\n" yield head + meta + frame["report"] def _agent(state, question, model): """Stream one Agent Mode turn as a running markdown transcript (single-turn).""" if not state: yield "_Upload a packet first._" return q = (question or "").strip() if not q: yield "_Ask a question to begin._" return head = f"**You:** {q}\n\n---\n" body = "" yield head + "_⏳ reading the agenda…_" for fr in backend.agent_chat(state["upload_id"], [{"role": "user", "content": q}], model): st = fr["stage"] if st == "thinking": body += f"\n*§ {fr['text']}*\n" elif st == "tool_call": args = ", ".join(f"{k}={v}" for k, v in (fr["args"] or {}).items()) body += f"\n`→ {fr['tool']}({args})`\n" elif st == "tool_result": body += f"\n↳ {fr['result'][:160]}…\n" elif st == "answer": body += f"\n\n**Agent:** {fr['text']}\n\n_— {fr['model']}_" elif st == "error": body += f"\n\n❌ {fr['text']}" yield head + body def build_demo() -> gr.Blocks: model_choices = [("Gemma 4 E4B — fast", "e4b"), ("Gemma 4 26B — detailed", "26b")] with gr.Blocks(title="Agenda Parser") as demo: doc_state = gr.State(None) gr.Markdown( "# Agenda Parser\n" "Upload a meeting **agenda-packet PDF**, give the agenda (table-of-contents) " "page range, and parse it into items mapped to their backup pages — then " "summarize, report on an item, view its pages, or ask the agent. Model runs " "in-process on ZeroGPU." ) with gr.Row(): with gr.Column(scale=2): gr.Markdown("### 📄 Upload") file_in = gr.File(label="Agenda packet PDF", file_types=[".pdf"], type="filepath") pages_in = gr.Textbox(label="Agenda pages", value="1-3", placeholder="e.g. 1-3") model = gr.Dropdown(choices=model_choices, value="e4b", label="Model") upload_btn = gr.Button("Upload & parse", variant="primary") items_md = gr.Markdown("_Upload a packet to load its agenda items._") item_dd = gr.Dropdown(choices=[], label="Agenda item") with gr.Column(scale=3): with gr.Tab("Summarize"): sum_btn = gr.Button("Summarize agenda", variant="primary") sum_md = gr.Markdown("_Upload a packet, then click Summarize._") with gr.Tab("Agenda Item Report"): report_q = gr.Textbox( label="Your question", lines=2, placeholder="e.g. What is being approved, and what does it cost?", ) rep_engine = gr.Dropdown( choices=[("Semantic search — fastest", "single"), ("Full read — every section", "mapreduce")], value="single", label="Reading method", ) thoroughness = gr.Slider( 10, 120, value=50, step=10, label="Thoroughness (context fed / sections mined)", ) rep_btn = gr.Button("Generate item report", variant="primary") rep_md = gr.Markdown( "_Pick an agenda item, ask a question, then Generate item report._" ) gr.Markdown("---\n#### 📄 Packet pages for this item") pages_btn = gr.Button("View packet pages for this item") pages_info = gr.Markdown( "_Pick an agenda item, then view its packet pages._" ) pages_gallery = gr.Gallery( label="Agenda packet pages", columns=2, height=520, object_fit="contain", show_label=False, ) with gr.Tab("Agent"): gr.Markdown( "Ask in plain language — the agent reads this agenda packet " "and shows its work." ) agent_q = gr.Textbox( label="Your question", lines=2, placeholder="e.g. What's on this agenda? · " "Search the packet for the budget.", ) agent_btn = gr.Button("Ask the agent", variant="primary") agent_md = gr.Markdown("_Upload a packet, then ask._") upload_btn.click(_upload, [file_in, pages_in], [items_md, item_dd, doc_state]) sum_btn.click(_summarize, [doc_state, model], sum_md) rep_btn.click(_item_report, [doc_state, item_dd, report_q, thoroughness, rep_engine, model], rep_md) pages_btn.click(_view_pages, [doc_state, item_dd], [pages_gallery, pages_info]) agent_btn.click(_agent, [doc_state, agent_q, model], agent_md) return demo # Module-level Blocks so HF's Gradio SDK can auto-detect `demo` when this file is # the Space app_file. Cheap to build (no model load). demo = build_demo() def main() -> None: if os.getenv("LLM_BACKEND", "remote").strip().lower() == "local": from webapp import local_llm local_llm.prefetch_models() print("[startup] models ready.", flush=True) demo.launch( server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"), server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")), ) if __name__ == "__main__": main()