spacedout-bits commited on
Commit
ad12eba
·
verified ·
1 Parent(s): bfd6783

Switch to Gradio SDK for free ZeroGPU tier

Browse files
Files changed (4) hide show
  1. README.md +13 -4
  2. app.py +51 -23
  3. requirements.txt +12 -3
  4. tests/test_app.py +2 -2
README.md CHANGED
@@ -3,16 +3,25 @@ title: Oracle
3
  emoji: 💸
4
  colorFrom: green
5
  colorTo: indigo
6
- sdk: docker
7
- app_port: 7860
 
8
  pinned: false
9
  short_description: Expense tracking and budget coaching over Telegram
10
  ---
11
 
12
  # coco-finbot
13
 
14
- > Deployed as the **Oracle** Space. `sdk: docker` above is what switches the
15
- > Space off Gradio — do not drop it.
 
 
 
 
 
 
 
 
16
 
17
  A personal finance bot that lives in Telegram and runs as a Hugging Face Docker
18
  Space. Tell it what you spent, send it receipts and bank statements, and ask it
 
3
  emoji: 💸
4
  colorFrom: green
5
  colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 5.50.0
8
+ app_file: app.py
9
  pinned: false
10
  short_description: Expense tracking and budget coaching over Telegram
11
  ---
12
 
13
  # coco-finbot
14
 
15
+ > Deployed as the **Oracle** Space on `sdk: gradio`.
16
+ >
17
+ > The bot is FastAPI; Gradio is only there to satisfy the platform. Since
18
+ > mid-2026, Docker Spaces require PRO, and the one free hardware tier for
19
+ > personal accounts — ZeroGPU — refuses any SDK but Gradio. So `app.py` builds
20
+ > the FastAPI app, mounts a one-page Gradio UI at `/` via
21
+ > `gr.mount_gradio_app`, and serves both. No GPU is used: inference goes out
22
+ > over HTTP to HF Inference Providers.
23
+ >
24
+ > The `Dockerfile` is still current and is what you'd use to self-host.
25
 
26
  A personal finance bot that lives in Telegram and runs as a Hugging Face Docker
27
  Space. Tell it what you spent, send it receipts and bank statements, and ask it
app.py CHANGED
@@ -12,9 +12,10 @@ import os
12
  from contextlib import asynccontextmanager
13
  from typing import Optional
14
 
 
15
  from fastapi import BackgroundTasks, FastAPI, Header, Request
16
  from fastapi.concurrency import run_in_threadpool
17
- from fastapi.responses import HTMLResponse, JSONResponse
18
 
19
  from finbot import __version__
20
  from finbot.advice import Advisor
@@ -127,28 +128,6 @@ app = FastAPI(title="coco-finbot", version=__version__, lifespan=lifespan)
127
  # --------------------------------------------------------------------- pages
128
 
129
 
130
- @app.get("/", response_class=HTMLResponse)
131
- async def index() -> str:
132
- """Deliberately stateless.
133
-
134
- This Space may be public, and transaction counts, configuration gaps and
135
- sync state are personal operational detail. They are available to the owner
136
- over Telegram via /status, and here only behind /admin/diagnostics.
137
- """
138
- return f"""<!doctype html>
139
- <meta charset="utf-8"><title>Oracle</title>
140
- <style>
141
- body {{ font: 16px/1.6 system-ui, sans-serif; max-width: 40rem;
142
- margin: 4rem auto; padding: 0 1rem; }}
143
- a {{ color: inherit; }}
144
- </style>
145
- <h1>💸 Oracle</h1>
146
- <p>A private expense-tracking and budget-coaching bot. It runs on Telegram —
147
- there is nothing to use on this page.</p>
148
- <p>Built with <code>coco-finbot</code> v{__version__}.</p>
149
- """
150
-
151
-
152
  @app.get("/health")
153
  async def health() -> JSONResponse:
154
  """Liveness only. No ledger or configuration detail — this may be public."""
@@ -344,3 +323,52 @@ async def admin_setup(
344
  await telegram.set_my_commands(services.registry.telegram_commands())
345
  info = await telegram.get_webhook_info()
346
  return JSONResponse({"ok": True, "webhook": info})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  from contextlib import asynccontextmanager
13
  from typing import Optional
14
 
15
+ import gradio as gr
16
  from fastapi import BackgroundTasks, FastAPI, Header, Request
17
  from fastapi.concurrency import run_in_threadpool
18
+ from fastapi.responses import JSONResponse
19
 
20
  from finbot import __version__
21
  from finbot.advice import Advisor
 
128
  # --------------------------------------------------------------------- pages
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  @app.get("/health")
132
  async def health() -> JSONResponse:
133
  """Liveness only. No ledger or configuration detail — this may be public."""
 
323
  await telegram.set_my_commands(services.registry.telegram_commands())
324
  info = await telegram.get_webhook_info()
325
  return JSONResponse({"ok": True, "webhook": info})
326
+
327
+
328
+ # ----------------------------------------------------------------- gradio UI
329
+
330
+
331
+ def build_ui() -> gr.Blocks:
332
+ """The Space's visible page.
333
+
334
+ Deliberately stateless. This Space is public, and transaction counts,
335
+ configuration gaps and sync state are personal operational detail — the
336
+ owner reads those over Telegram via /status, or from /admin/diagnostics
337
+ behind the webhook secret.
338
+ """
339
+ with gr.Blocks(title="Oracle") as demo:
340
+ gr.Markdown(
341
+ f"""
342
+ # 💸 Oracle
343
+
344
+ An expense-tracking and budget-coaching bot. **It runs on Telegram** —
345
+ there is nothing to operate on this page.
346
+
347
+ Tell it what you spent (`coffee 250`), send it a receipt photo, or
348
+ forward a bank statement as PDF/CSV/XLSX. Ask it `/month`,
349
+ `/recurring` or `/advice`.
350
+
351
+ Each Telegram user gets their own separate ledger.
352
+
353
+ <sub>Built with <code>coco-finbot</code> v{__version__}.</sub>
354
+ """
355
+ )
356
+ return demo
357
+
358
+
359
+ # Gradio owns "/", the FastAPI routes above keep their own paths. The Space
360
+ # must declare sdk: gradio -- ZeroGPU (the only free hardware for personal
361
+ # accounts) refuses the Docker SDK, and Docker Spaces now require PRO.
362
+ app = gr.mount_gradio_app(app, build_ui(), path="/")
363
+
364
+
365
+ if __name__ == "__main__":
366
+ import uvicorn
367
+
368
+ # Spaces sets PORT; 7860 is the platform default.
369
+ uvicorn.run(
370
+ app,
371
+ host="0.0.0.0",
372
+ port=int(os.environ.get("PORT", 7860)),
373
+ log_level=os.environ.get("LOG_LEVEL", "info").lower(),
374
+ )
requirements.txt CHANGED
@@ -1,9 +1,18 @@
1
- fastapi>=0.115,<0.121
2
- uvicorn[standard]>=0.32,<0.40
 
 
 
 
 
 
 
 
 
 
3
  httpx>=0.27,<0.29
4
  pydantic>=2.9,<3
5
  pydantic-settings>=2.6,<3
6
  huggingface_hub>=0.34,<1.0
7
- pdfplumber>=0.11,<0.12
8
  openpyxl>=3.1,<4
9
  python-dateutil>=2.9,<3
 
1
+ # Gradio is required by the Space SDK, not by the bot itself: ZeroGPU (the only
2
+ # free hardware for personal accounts since mid-2026) is Gradio-only, and the
3
+ # Docker SDK now needs PRO. The webhook is plain FastAPI mounted alongside it.
4
+ gradio>=5.0,<6
5
+
6
+ # pdfplumber 0.11.10 requires Pillow>=12.2, which gradio (pillow<12) forbids.
7
+ # 0.11.9 needs only Pillow>=9.1, so the two coexist. Do not bump without
8
+ # checking gradio's pillow ceiling first.
9
+ pdfplumber>=0.11.9,<0.11.10
10
+
11
+ fastapi>=0.115
12
+ uvicorn[standard]>=0.32
13
  httpx>=0.27,<0.29
14
  pydantic>=2.9,<3
15
  pydantic-settings>=2.6,<3
16
  huggingface_hub>=0.34,<1.0
 
17
  openpyxl>=3.1,<4
18
  python-dateutil>=2.9,<3
tests/test_app.py CHANGED
@@ -96,7 +96,7 @@ class TestEndpoints:
96
  body = client.get("/health").json()
97
  assert set(body) == {"status", "version"}
98
 
99
- def test_index_renders(self, client):
100
  response = client.get("/")
101
  assert response.status_code == 200
102
  assert "Oracle" in response.text
@@ -104,7 +104,7 @@ class TestEndpoints:
104
  def test_index_leaks_no_ledger_detail(self, client):
105
  send(client, "coffee 250")
106
  text = client.get("/").text.lower()
107
- for leak in ("rows", "ledger", "mirroring", "not configured", "hf_dataset_repo"):
108
  assert leak not in text
109
 
110
  def test_diagnostics_requires_the_secret(self, client):
 
96
  body = client.get("/health").json()
97
  assert set(body) == {"status", "version"}
98
 
99
+ def test_index_serves_the_gradio_ui(self, client):
100
  response = client.get("/")
101
  assert response.status_code == 200
102
  assert "Oracle" in response.text
 
104
  def test_index_leaks_no_ledger_detail(self, client):
105
  send(client, "coffee 250")
106
  text = client.get("/").text.lower()
107
+ for leak in ("mirroring", "not configured", "hf_dataset_repo", "telegram_bot_token"):
108
  assert leak not in text
109
 
110
  def test_diagnostics_requires_the_secret(self, client):