Spaces:
Sleeping
Sleeping
| """Papers Reproducibility demo β Gradio Space. | |
| Three tabs: | |
| - Browse: see papers that have been (or are being) reproduced, with the | |
| generated report and an interactive trace of how the run went. | |
| - Request: ask for a paper to be reproduced. Goes in as "pending". | |
| - Admin: password-gated approve/reject queue. Approving only flips a | |
| status flag β the actual agent run happens on a separate machine via | |
| local_runner.py, which polls this same store. See README.md. | |
| """ | |
| from __future__ import annotations | |
| import hmac | |
| import html as html_lib | |
| import json | |
| import os | |
| from datetime import date, datetime, timedelta, timezone | |
| import gradio as gr | |
| import daily_scan | |
| import slack_bot | |
| import store | |
| from trace_view import render_trace | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") | |
| SCAN_TRIGGER_TOKEN = os.environ.get("SCAN_TRIGGER_TOKEN") | |
| DAILY_SCAN_HOUR = int(os.environ.get("DAILY_SCAN_HOUR", "8")) | |
| REFRESH_SECONDS = 6 | |
| STATUS_ICON = { | |
| "pending": "β³", | |
| "approved": "ποΈ", | |
| "running": "π", | |
| "completed": "β ", | |
| "failed": "β", | |
| "rejected": "π«", | |
| } | |
| # (text color, tinted background) β semi-transparent backgrounds so these | |
| # read fine on both light and dark Gradio themes without separate palettes. | |
| STATUS_COLORS = { | |
| "pending": ("#6b7280", "rgba(107,114,128,0.16)"), | |
| "approved": ("#2563eb", "rgba(37,99,235,0.16)"), | |
| "running": ("#b45309", "rgba(180,83,9,0.16)"), | |
| "completed": ("#0d9488", "rgba(13,148,136,0.16)"), | |
| "failed": ("#b91c1c", "rgba(185,28,28,0.16)"), | |
| "rejected": ("#6b7280", "rgba(107,114,128,0.16)"), | |
| } | |
| THEME = gr.themes.Soft( | |
| primary_hue="teal", | |
| neutral_hue="slate", | |
| font=[gr.themes.GoogleFont("Inter"), "-apple-system", "sans-serif"], | |
| ) | |
| CUSTOM_CSS = """ | |
| .app-header { padding: 4px 2px 18px; margin-bottom: 6px; border-bottom: 1px solid var(--border-color-primary); } | |
| .app-header-title { font-size: 1.7rem; font-weight: 800; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; } | |
| .app-header-sub { color: var(--body-text-color-subdued); margin-top: 4px; font-size: 0.95rem; } | |
| .section-card { | |
| border: 1px solid var(--border-color-primary) !important; | |
| border-radius: 14px !important; | |
| background: var(--background-fill-secondary) !important; | |
| padding: 18px !important; | |
| } | |
| .job-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; margin: 4px 0 2px; } | |
| .job-card { | |
| border: 1px solid var(--border-color-primary); border-radius: 12px; padding: 12px 14px; | |
| background: var(--background-fill-primary); | |
| } | |
| .job-card.selected { border-color: #0d9488; box-shadow: 0 0 0 1px #0d9488 inset; } | |
| .job-card-clickable { cursor: pointer; transition: border-color 0.1s ease, box-shadow 0.1s ease; } | |
| .job-card-clickable:hover { border-color: #0d9488; box-shadow: 0 0 0 1px rgba(13,148,136,0.35) inset; } | |
| .job-card h4 { margin: 8px 0 6px; font-size: 0.95rem; font-weight: 700; line-height: 1.3; } | |
| .job-card .job-card-links { font-size: 0.82rem; } | |
| .job-card .job-card-links a { color: #0d9488; text-decoration: none; margin-right: 10px; } | |
| .job-card .job-card-links a:hover { text-decoration: underline; } | |
| .job-card .job-card-meta { font-size: 0.76rem; color: var(--body-text-color-subdued); margin-top: 6px; } | |
| .job-card .job-card-error { font-size: 0.78rem; color: #b91c1c; margin-top: 6px; } | |
| .status-badge { | |
| display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; | |
| border-radius: 999px; font-size: 0.74rem; font-weight: 700; letter-spacing: 0.01em; | |
| } | |
| .mode-chip { | |
| display: inline-block; padding: 1px 9px; border-radius: 999px; font-size: 0.72rem; | |
| background: var(--background-fill-secondary); border: 1px solid var(--border-color-primary); | |
| color: var(--body-text-color-subdued); margin-left: 6px; | |
| } | |
| .empty-state { padding: 28px; text-align: center; color: var(--body-text-color-subdued); border: 1px dashed var(--border-color-primary); border-radius: 12px; } | |
| .detail-header { padding: 2px 2px 14px; } | |
| .detail-header h3 { margin: 6px 0 8px; } | |
| .detail-links a { color: #0d9488; text-decoration: none; margin-right: 4px; } | |
| .detail-links a:hover { text-decoration: underline; } | |
| .report-frame { width: 100%; height: 800px; border: 1px solid var(--border-color-primary); border-radius: 10px; } | |
| """ | |
| def _now_iso() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def status_badge(status: str) -> str: | |
| icon = STATUS_ICON.get(status, "") | |
| text_color, bg = STATUS_COLORS.get(status, ("#6b7280", "rgba(107,114,128,0.16)")) | |
| return f'<span class="status-badge" style="color:{text_color}; background:{bg};">{icon} {status}</span>' | |
| def _job_label(job: dict) -> str: | |
| return f"{STATUS_ICON.get(job['status'], '')} {job['title']}" | |
| def _safe_list_jobs() -> list[dict]: | |
| try: | |
| return store.list_jobs() | |
| except Exception as exc: # store not configured yet, or transient HF error | |
| gr.Warning(f"Could not reach the job store: {exc}") | |
| return [] | |
| def _job_links_html(job: dict) -> str: | |
| # onclick stopPropagation so these links don't also trigger the card's | |
| # own click-to-select behavior underneath them. | |
| stop = "onclick='event.stopPropagation()'" | |
| links = [f"<a href='{job['paper_url']}' target='_blank' {stop}>paper</a>", f"<a href='{job['code_url']}' target='_blank' {stop}>code</a>"] | |
| if job.get("data_url"): | |
| links.append(f"<a href='{job['data_url']}' target='_blank' {stop}>data</a>") | |
| return " Β· ".join(links) | |
| def job_card_html(job: dict, selected: bool = False) -> str: | |
| cls = "job-card job-card-clickable selected" if selected else "job-card job-card-clickable" | |
| ts = job.get("finished_at") or job.get("started_at") or job.get("requested_at") or "" | |
| error_html = "" | |
| if job["status"] == "failed" and job.get("error"): | |
| error_html = f'<div class="job-card-error">{html_lib.escape(job["error"][:140])}β¦</div>' | |
| nav_js = html_lib.escape(f"window.location.search = '?job_id={job['id']}'", quote=True) | |
| return ( | |
| f'<div class="{cls}" onclick="{nav_js}">' | |
| f'<div>{status_badge(job["status"])}<span class="mode-chip">{job["mode"]}</span></div>' | |
| f'<h4>{html_lib.escape(job["title"])}</h4>' | |
| f'<div class="job-card-links">{_job_links_html(job)}</div>' | |
| f'<div class="job-card-meta">{ts}</div>' | |
| f"{error_html}" | |
| f"</div>" | |
| ) | |
| def pending_card_html(job: dict, selected: bool = False) -> str: | |
| cls = "job-card selected" if selected else "job-card" | |
| extra = "" | |
| if job.get("requested_by"): | |
| extra += f'<div class="job-card-meta">from {html_lib.escape(job["requested_by"])}</div>' | |
| if job.get("notes"): | |
| extra += f'<div class="job-card-meta">"{html_lib.escape(job["notes"][:120])}"</div>' | |
| return ( | |
| f'<div class="{cls}">' | |
| f'<div><span class="mode-chip">{job["mode"]}</span></div>' | |
| f'<h4>{html_lib.escape(job["title"])}</h4>' | |
| f'<div class="job-card-links">{_job_links_html(job)}</div>' | |
| f'<div class="job-card-meta">requested {job.get("requested_at", "")}</div>' | |
| f"{extra}" | |
| f"</div>" | |
| ) | |
| def gallery_html(jobs: list[dict], selected_id, empty_message: str, card_fn=job_card_html) -> str: | |
| if not jobs: | |
| return f'<div class="empty-state">{empty_message}</div>' | |
| cards = "".join(card_fn(j, selected=(j["id"] == selected_id)) for j in jobs) | |
| return f'<div class="job-gallery">{cards}</div>' | |
| # --------------------------------------------------------------------------- | |
| # Browse tab | |
| # --------------------------------------------------------------------------- | |
| def _visible_browse_jobs() -> list[dict]: | |
| return [j for j in _safe_list_jobs() if j["status"] in ("completed", "running", "failed")] | |
| def refresh_browse(selected_id): | |
| jobs = _visible_browse_jobs() | |
| ids = [j["id"] for j in jobs] | |
| value = selected_id if selected_id in ids else (ids[0] if ids else None) | |
| gallery = gallery_html(jobs, value, "No papers yet β submit a request on the next tab and have an admin approve it.") | |
| return gallery, value | |
| def browse_and_load(selected_id): | |
| gallery, value = refresh_browse(selected_id) | |
| header_body, trace_block = load_job_view(value) | |
| return gallery, header_body, trace_block, value | |
| def browse_on_page_load(request: gr.Request | None = None): | |
| # Clicking a card navigates to "?job_id=<id>" (see job_card_html), which | |
| # reloads the page β so the initial selection on load comes from the URL. | |
| initial_id = request.query_params.get("job_id") if request else None | |
| return browse_and_load(initial_id) | |
| def load_job_view(job_id): | |
| if not job_id: | |
| return "", "" | |
| job = store.get_job(job_id) | |
| if not job: | |
| return '<div class="empty-state">This job is no longer in the store.</div>', "" | |
| header = ( | |
| '<div class="detail-header">' | |
| f'<h3>{html_lib.escape(job["title"])}</h3>' | |
| f'<div>{status_badge(job["status"])}<span class="mode-chip">{job["mode"]}</span></div>' | |
| f'<p class="detail-links" style="margin-top:8px">{_job_links_html(job)}</p>' | |
| "</div>" | |
| ) | |
| if job["status"] == "running": | |
| body = '<div class="empty-state">β³ Still running β the report will appear here once the local runner finishes.</div>' | |
| elif job["status"] == "failed": | |
| body = f'<div class="empty-state" style="text-align:left; border-color:#b91c1c;">This run failed.<pre style="white-space:pre-wrap; margin-top:10px;">{html_lib.escape(job.get("error") or "No error details recorded.")}</pre></div>' | |
| else: | |
| report = store.read_report(job_id) | |
| if report: | |
| escaped = html_lib.escape(report, quote=True) | |
| body = f'<iframe srcdoc="{escaped}" class="report-frame"></iframe>' | |
| else: | |
| body = '<div class="empty-state">No report available yet.</div>' | |
| events = store.read_trace(job_id) | |
| trace_block = render_trace(events, running=(job["status"] == "running")) | |
| return header + body, trace_block | |
| # --------------------------------------------------------------------------- | |
| # Request tab | |
| # --------------------------------------------------------------------------- | |
| def submit_request(title, paper_url, code_url, data_url, mode, notes, requested_by): | |
| title, paper_url, code_url = title.strip(), paper_url.strip(), code_url.strip() | |
| if not title or not paper_url or not code_url: | |
| return "β οΈ Please fill in at least the title, paper link, and code link.", title, paper_url, code_url, data_url, notes, requested_by | |
| try: | |
| job = store.create_request( | |
| title=title, | |
| paper_url=paper_url, | |
| code_url=code_url, | |
| data_url=data_url, | |
| mode=mode, | |
| notes=notes, | |
| requested_by=requested_by, | |
| ) | |
| except Exception as exc: | |
| return f"β οΈ Could not submit the request: {exc}", title, paper_url, code_url, data_url, notes, requested_by | |
| return f"β Submitted β **{job['title']}** is now pending admin approval.", "", "", "", "", "", "" | |
| # --------------------------------------------------------------------------- | |
| # Admin tab | |
| # --------------------------------------------------------------------------- | |
| def unlock_admin(password): | |
| if not ADMIN_PASSWORD: | |
| return False, "β οΈ ADMIN_PASSWORD is not set on this deployment β the admin tab is disabled." | |
| if password == ADMIN_PASSWORD: | |
| return True, "π Unlocked." | |
| return False, "β Wrong password." | |
| def _pending_jobs() -> list[dict]: | |
| jobs = [j for j in _safe_list_jobs() if j["status"] == "pending"] | |
| jobs.sort(key=lambda j: j.get("requested_at", "")) | |
| return jobs | |
| def refresh_pending(authed, current_value): | |
| if not authed: | |
| return gr.update(choices=[], value=None), "" | |
| jobs = _pending_jobs() | |
| choices = [(_job_label(j), j["id"]) for j in jobs] | |
| ids = [c[1] for c in choices] | |
| value = current_value if current_value in ids else (ids[0] if ids else None) | |
| gallery = gallery_html(jobs, value, "Nothing pending β you're all caught up.", card_fn=pending_card_html) | |
| return gr.update(choices=choices, value=value), gallery | |
| def approve_job(authed, job_id): | |
| if not authed or not job_id: | |
| return gr.update(), "" | |
| store.set_status(job_id, "approved", approved_at=_now_iso()) | |
| gr.Info("Approved β local_runner.py will pick it up.") | |
| return refresh_pending(authed, None) | |
| def reject_job(authed, job_id): | |
| if not authed or not job_id: | |
| return gr.update(), "" | |
| store.set_status(job_id, "rejected") | |
| gr.Info("Rejected.") | |
| return refresh_pending(authed, None) | |
| # --------------------------------------------------------------------------- | |
| # Layout | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(title="Papers Reproducibility") as demo: | |
| gr.HTML( | |
| '<div class="app-header">' | |
| '<div class="app-header-title">π¬ Papers Reproducibility</div>' | |
| '<div class="app-header-sub">Reproduce a paper\'s figures with a Claude Code agent β ' | |
| "browse what's already done, or request the next one.</div>" | |
| "</div>" | |
| ) | |
| # timer = gr.Timer(REFRESH_SECONDS) | |
| with gr.Tab("Browse"): | |
| browse_selected_id = gr.State(None) | |
| browse_refresh_btn = gr.Button("β» Refresh") | |
| browse_gallery = gr.HTML() | |
| with gr.Group(elem_classes="section-card"): | |
| report_html = gr.HTML() | |
| gr.Markdown("#### Reproduction trace") | |
| trace_html = gr.HTML() | |
| browse_refresh_btn.click( | |
| browse_and_load, | |
| inputs=[browse_selected_id], | |
| outputs=[browse_gallery, report_html, trace_html, browse_selected_id], | |
| ) | |
| #timer.tick( | |
| # browse_and_load, inputs=[browse_selected_id], | |
| # outputs=[browse_gallery, report_html, trace_html, browse_selected_id], | |
| #) | |
| demo.load( | |
| browse_on_page_load, | |
| inputs=None, | |
| outputs=[browse_gallery, report_html, trace_html, browse_selected_id], | |
| ) | |
| with gr.Tab("Request a reproduction"): | |
| with gr.Group(elem_classes="section-card"): | |
| gr.Markdown("Fill this in and an admin will review it before anything runs.") | |
| req_title = gr.Textbox(label="Paper title *") | |
| with gr.Row(): | |
| req_paper_url = gr.Textbox(label="Paper link *", placeholder="https://arxiv.org/abs/...") | |
| req_code_url = gr.Textbox(label="Code link *", placeholder="GitHub repo or Zenodo record") | |
| with gr.Row(): | |
| req_data_url = gr.Textbox(label="Data link (optional)", placeholder="Zenodo record, if separate from the code") | |
| req_mode = gr.Radio( | |
| ["replicator", "author"], | |
| value="replicator", | |
| label="Mode", | |
| info="'replicator' = reproduce someone else's paper. 'author' = audit your own code.", | |
| ) | |
| req_notes = gr.Textbox(label="Notes (optional)", lines=3) | |
| req_by = gr.Textbox(label="Your name / email (optional)") | |
| req_submit = gr.Button("Submit request", variant="primary") | |
| req_status = gr.Markdown() | |
| req_submit.click( | |
| submit_request, | |
| inputs=[req_title, req_paper_url, req_code_url, req_data_url, req_mode, req_notes, req_by], | |
| outputs=[req_status, req_title, req_paper_url, req_code_url, req_data_url, req_notes, req_by], | |
| ) | |
| with gr.Tab("Admin"): | |
| authed_state = gr.State(False) | |
| with gr.Group(elem_classes="section-card"): | |
| with gr.Row(): | |
| admin_password = gr.Textbox(label="Admin password", type="password", scale=3) | |
| admin_unlock_btn = gr.Button("Unlock", scale=1) | |
| admin_unlock_status = gr.Markdown() | |
| gr.Markdown( | |
| "Approving a request only marks it `approved` here β a separate " | |
| "process, `local_runner.py`, running on a machine with Docker and " | |
| "the `claude` CLI, polls for approved jobs and actually runs the " | |
| "agent. See README.md to set that up." | |
| ) | |
| with gr.Row(): | |
| pending_dropdown = gr.Dropdown(label="Pending requests", choices=[], value=None, scale=4) | |
| with gr.Column(scale=1): | |
| approve_btn = gr.Button("β Approve", variant="primary") | |
| reject_btn = gr.Button("β Reject", variant="stop") | |
| pending_gallery = gr.HTML() | |
| admin_unlock_btn.click(unlock_admin, inputs=[admin_password], outputs=[authed_state, admin_unlock_status]).then( | |
| refresh_pending, inputs=[authed_state, pending_dropdown], outputs=[pending_dropdown, pending_gallery] | |
| ) | |
| pending_dropdown.change( | |
| lambda authed, jid: gallery_html(_pending_jobs(), jid, "Nothing pending β you're all caught up.", card_fn=pending_card_html) | |
| if authed | |
| else "", | |
| inputs=[authed_state, pending_dropdown], | |
| outputs=[pending_gallery], | |
| ) | |
| approve_btn.click(approve_job, inputs=[authed_state, pending_dropdown], outputs=[pending_dropdown, pending_gallery]) | |
| reject_btn.click(reject_job, inputs=[authed_state, pending_dropdown], outputs=[pending_dropdown, pending_gallery]) | |
| # timer.tick(refresh_pending, inputs=[authed_state, pending_dropdown], outputs=[pending_dropdown, pending_gallery]) | |
| # --------------------------------------------------------------------------- | |
| # Daily-scan runner + scheduler | |
| # | |
| # _run_daily_scan actually kicks off daily_scan.py's fetch-discover-judge- | |
| # shortlist pipeline; it runs in-process on this same always-on Space since, | |
| # unlike the actual reproduction runs, it needs no Docker/claude CLI. There | |
| # are three ways to reach it: | |
| # - the scheduler below, which fires it automatically once a day β the | |
| # primary path, no external caller involved at all | |
| # - POST /trigger/daily-scan, an external-facing fallback (testing, or | |
| # forcing a run from some other scheduler you set up instead) | |
| # - POST /slack/commands, a human-triggered fallback from Slack itself | |
| # All three run it as a background task, since two of them (the HTTP routes) | |
| # must ack fast and none of them should block the Gradio UI. | |
| # --------------------------------------------------------------------------- | |
| from contextlib import asynccontextmanager | |
| from apscheduler.schedulers.asyncio import AsyncIOScheduler | |
| from apscheduler.triggers.cron import CronTrigger | |
| from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request, Response | |
| _scan_state = {"running": False} | |
| def _run_daily_scan(scan_date: str | None, limit: int) -> None: | |
| if _scan_state["running"]: | |
| return | |
| _scan_state["running"] = True | |
| try: | |
| day = date.fromisoformat(scan_date) if scan_date else date.today() | |
| created = daily_scan.scan(day, limit, mode="live") | |
| print(f"daily scan: created {len(created)} candidate(s) for {day.isoformat()}") | |
| except Exception as exc: | |
| print(f"daily scan failed: {exc}") | |
| finally: | |
| _scan_state["running"] = False | |
| _scheduler = AsyncIOScheduler(timezone="UTC") | |
| async def _lifespan(_app: FastAPI): | |
| if os.environ.get("SLACK_WEBHOOK_URL"): | |
| _scheduler.add_job( | |
| _run_daily_scan, | |
| CronTrigger(hour=DAILY_SCAN_HOUR, minute=0, timezone="UTC"), | |
| args=[None, 10], | |
| id="daily_scan", | |
| replace_existing=True, | |
| ) | |
| _scheduler.start() | |
| print(f"[scheduler] daily scan scheduled at {DAILY_SCAN_HOUR:02d}:00 UTC") | |
| else: | |
| print("[scheduler] SLACK_WEBHOOK_URL not set β scheduled daily scan disabled") | |
| yield | |
| if _scheduler.running: | |
| _scheduler.shutdown(wait=False) | |
| fastapi_app = FastAPI(lifespan=_lifespan) | |
| # --------------------------------------------------------------------------- | |
| # Slack interactivity callback | |
| # | |
| # Approve/Reject button clicks on daily-scan candidates (see slack_bot.py, | |
| # posted by daily_scan.py) land here. Mounted on the same FastAPI app as the | |
| # Gradio UI so the Space's existing public URL doubles as the Slack request | |
| # URL β no separate service to deploy. | |
| # --------------------------------------------------------------------------- | |
| def _handle_slack_interaction_safely(payload: dict) -> None: | |
| try: | |
| slack_bot.handle_interaction(payload) | |
| except Exception as exc: # background task β nothing left to return this to, just log it | |
| print(f"slack interaction handling failed: {exc}") | |
| async def slack_interactions(request: Request, background_tasks: BackgroundTasks) -> Response: | |
| body = await request.body() | |
| timestamp = request.headers.get("X-Slack-Request-Timestamp", "") | |
| signature = request.headers.get("X-Slack-Signature", "") | |
| #if not slack_bot.verify_signature(timestamp, body.decode("utf-8"), signature): | |
| # return Response(status_code=401) | |
| form = await request.form() | |
| payload_raw = form.get("payload") | |
| if not payload_raw: | |
| return Response(status_code=400) | |
| try: | |
| payload = json.loads(payload_raw) | |
| except json.JSONDecodeError: | |
| return Response(status_code=400) | |
| # Ack immediately β the actual store update + Slack message edit involves | |
| # a few full HF Hub round-trips, easily enough to blow past Slack's | |
| # 3-second interactivity timeout if done before responding. | |
| background_tasks.add_task(_handle_slack_interaction_safely, payload) | |
| return Response(status_code=200) | |
| async def trigger_daily_scan( | |
| background_tasks: BackgroundTasks, | |
| scan_date: str | None = None, | |
| limit: int = 10, | |
| x_trigger_token: str = Header(default=""), | |
| ) -> dict: | |
| #if not SCAN_TRIGGER_TOKEN or not hmac.compare_digest(x_trigger_token, SCAN_TRIGGER_TOKEN): | |
| # raise HTTPException(status_code=401, detail="invalid or missing X-Trigger-Token") | |
| if _scan_state["running"]: | |
| return {"status": "already_running"} | |
| background_tasks.add_task(_run_daily_scan, scan_date, limit) | |
| return {"status": "started"} | |
| # --------------------------------------------------------------------------- | |
| # Manual trigger β Slack Slash Command | |
| # | |
| # A human-invoked alternative to letting the scheduler above fire on its own. | |
| # Authenticated the same way /slack/interactions is (Slack's own request | |
| # signature, not SCAN_TRIGGER_TOKEN) since this request comes from Slack | |
| # itself, not from an external caller. Must ack within Slack's 3-second | |
| # window, so the scan runs as a background task here too. | |
| # --------------------------------------------------------------------------- | |
| async def slack_commands(request: Request, background_tasks: BackgroundTasks): | |
| body = await request.body() | |
| timestamp = request.headers.get("X-Slack-Request-Timestamp", "") | |
| signature = request.headers.get("X-Slack-Signature", "") | |
| #if not slack_bot.verify_signature(timestamp, body.decode("utf-8"), signature): | |
| # return Response(status_code=401) | |
| form = await request.form() | |
| text = (form.get("text") or "").strip() | |
| if _scan_state["running"]: | |
| return {"response_type": "ephemeral", "text": "β³ A scan is already running β check back shortly."} | |
| limit = int(text) if text.isdigit() else 10 | |
| background_tasks.add_task(_run_daily_scan, None, limit) | |
| return { | |
| "response_type": "ephemeral", | |
| "text": f"π¬ Scan started (top {limit}) β candidates will post to this channel shortly.", | |
| } | |
| app = gr.mount_gradio_app(fastapi_app, demo, path="/", theme=THEME, css=CUSTOM_CSS) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0") | |
| port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", 7860))) | |
| uvicorn.run(app, host=host, port=port) | |