|
Download docs/blog-offgridschedula.md from ParetoOptimal/OffGridSchedula: direct link, hf CLI and curl.
- Browser
- Download file 18.5 kB
-
https://huggingface.co/spaces/ParetoOptimal/OffGridSchedula/resolve/main/docs/blog-offgridschedula.md
- Command line
-
hf download hf://spaces/ParetoOptimal/OffGridSchedula/docs/blog-offgridschedula.md
-
curl -L -o blog-offgridschedula.md https://huggingface.co/spaces/ParetoOptimal/OffGridSchedula/resolve/main/docs/blog-offgridschedula.md
18.5 kB
| # Off the Grid, On Schedule | |
| *A solo Build Small project β one person, two small local models, and a calendar problem worth solving.* | |
| ## TL;DR | |
| - **OffGridSchedula** turns a pasted group chat β or a screenshot of a flyer or invite β into calendar events, with conflicts caught and a reply drafted, then a local `.ics` you own. | |
| - It runs a **fine-tuned Gemma 4** through **llama.cpp**, **in the Space**. No cloud AI APIs, no account, no app to install. | |
| - A full **QLoRA β GGUF** fine-tuning pipeline, built solo, ships behind an **eval gate** that refuses to publish a model that doesn't clear the bar β and it produced an own edge model at parity with the base. | |
| - **Run the agents** runs a **two-model team**, both local and both β€ 32B: **OpenBMB MiniCPM5-1B** plans the steps and the fine-tuned **gemma-cal E4B** does the extraction. | |
| - It's also an **MCP server**: any MCP-aware agent can call `extract_events` / `make_ics` / `check_conflicts` as tools. | |
| **π Try it:** [build-small-hackathon/OffGridSchedula](https://huggingface.co/spaces/build-small-hackathon/OffGridSchedula) Β· **Model:** [gemma-4-cal-gguf](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) Β· **Planner:** [MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) Β· **Traces:** [offgridschedula-traces](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces) | |
| ## Contents | |
| - [The problem](#the-problem) | |
| - [What it does](#what-it-does) | |
| - [Architecture](#architecture) | |
| - [Fine-tuning Gemma 4 for calendars](#fine-tuning-gemma-4-for-calendars) | |
| - [Off the grid: llama.cpp in a Space](#off-the-grid-llamacpp-in-a-space) | |
| - [From app to agent: MCP, memory, and shared traces](#from-app-to-agent-mcp-memory-and-shared-traces) | |
| - [Built small, by the rules: constraints & badges](#built-small-by-the-rules-constraints--badges) | |
| - [Lessons learned](#lessons-learned) | |
| - [What's next](#whats-next) | |
| - [Get started](#get-started) | |
| - [Acknowledgements & citation](#acknowledgements--citation) | |
| ## The problem | |
| Every parent knows this chat. The class group thread scrolls past: *"Picture day is Thursday at 9am, wear the green shirt."* *"Practice moves to Tuesday 5pm this week."* *"RSVP by Friday for the party."* You read it once, mean to add it later, and miss it. | |
| That's the person this was built for β not "users" in the abstract, but one busy parent whose kid's school and activity events are buried in noise. It's an honest fit for a small, local model: the input is a handful of recent messages or a screenshot, the output is a few calendar events, and the whole thing should work from a phone browser with nothing to install. | |
|  | |
| *The landing page. Paste a chat or a screenshot; get events back.* | |
| ## What it does | |
| Paste the chat (or attach a screenshot of a flyer/invite). The agent returns: | |
| 1. **The events** β title, start/end, location, a sensible default reminder. | |
| 2. **A conflict check** against a calendar you upload (`.ics`), with clashes flagged. | |
| 3. **A ready-to-send reply** ("Got it β adding both, thanks!"). | |
| Everything is **surfaced for review before anything is saved**. The output is a local `.ics` file you can import into any calendar, with an optional one-click Google Calendar push. No inbox access, no auto-reading β it reads only what you choose to paste. | |
| ## Architecture | |
| The app is a single **Gradio 6** Blocks UI mounted alongside a **FastAPI** app on one port, so the same Space serves the UI, a `/agent` endpoint, and an MCP server. | |
| **Two small models, two jobs.** Extraction is the fine-tuned **`gemma-cal` E4B** (~4B effective params): it reads the thread β with **vision**, for screenshots β and emits the single constrained `ActionPlan` below. A second model plans the run: **OpenBMB [MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF)** as the *planner*, which sequences the steps via the Space's own MCP tools when you click **Run the agents**. Both run locally through llama.cpp (the planner as a second `llama-server`); neither is over 32B, and nothing leaves the box. | |
| The model never decides *whether two events clash* β it decides **what** the events are; deterministic interval math decides **when they conflict**. The model emits a single constrained JSON object (a pydantic `ActionPlan`), grammar-constrained so it's always valid: | |
| ```python | |
| class Event(BaseModel): | |
| title: str | |
| start: str # ISO 8601 | |
| end: str | None = None | |
| location: str | None = None | |
| reminder_minutes: int | None = 30 | |
| class ActionPlan(BaseModel): | |
| reasoning: str | |
| events: list[Event] = [] | |
| conflicts: list[Conflict] = [] | |
| proposed_times: list[str] = [] | |
| reply_draft: str = "" | |
| needs_clarification: bool = False | |
| ``` | |
| Inference sits behind one seam β an `INFERENCE_BASE_URL` env var. Unset, the Space loads a GGUF and runs it in-process via llama.cpp. Set, it points at any OpenAI-compatible `llama-server` (e.g. one running on your own machine). Same app, two deployment shapes. | |
| A nice consequence: a **stub extractor** (`USE_STUB_EXTRACTOR=1`, a tiny regex heuristic) lets the entire app, the test suite, and the demo run with **no GPU at all**. The expensive dependency is optional from day one β which means the cheap path doubles as the CI harness and the free-tier preview. | |
| ## Fine-tuning Gemma 4 for calendars | |
| The fun part. **Gemma 4** was fine-tuned to read messy chats into the `ActionPlan` schema β and, just as importantly, paired with the **machinery to know whether a fine-tune is actually any good**. | |
| **The pipeline.** QLoRA via Unsloth (4-bit, r=16) β merge β `convert_hf_to_gguf.py` β `llama-quantize Q4_K_M` β published as [`build-small-hackathon/gemma-4-cal-gguf`](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf). Training data is hand-authored examples plus a **SMCalFlow** importer (Microsoft Semantic Machines, CC BY-SA 4.0) that resolves LISP "dataflow" calendar programs into concrete, self-consistent datetimes β directly training the relative-date skill ("next Thursday", "the 14th") against a spread of 2026 reference dates. | |
| **The real win: an eval gate.** Generic benchmarks (MMLU, etc.) don't apply to a narrow extraction task, so a task-specific scorer (`training/eval.py`) over a held-out set became the **publishing process** (`training/gated_retrain.py`): | |
| > Retrain β upload to a **staging** filename β score on the held-out eval β **gate** | |
| > (`schema_validity β₯ 0.95`, `event_f1 β₯ 0.81`, `start-exact recall β₯ 0.773`) β | |
| > **PASS** promotes staging to production (a free server-side copy); **FAIL** deletes staging, production untouched. | |
| No retrain reaches users unless it beats the current best. That gate has already earned its keep β it caught regressions on bigger retrains and refused to ship them, so production was never degraded by an experiment. | |
| Here's the fine-tune's scorecard (Q4_K_M, n=28), with the base model as context: | |
| | Metric | Fine-tune `gemma-4-cal` | Base Gemma 4 (context) | | |
| | --- | --- | --- | | |
| | schema validity | 1.00 | 1.00 | | |
| | no-event accuracy | 1.00 | 1.00 | | |
| | **clarification recall** | **1.00** | 0.75 | | |
| | end-time exact | 1.00 | 1.00 | | |
| | event precision | 0.85 | 1.00 | | |
| | start-exact recall | 0.77 | 0.955 | | |
| | event F1 | 0.81 | 0.977 | | |
| The standout is **clarification discipline** β the fine-tune reliably asks when a thread is "date TBD" instead of guessing, where the base model sometimes plows ahead. The base model is genuinely strong on this narrow task, so rather than argue about it, **the eval decides what production serves** β and the published fine-tune stands as the Well-Tuned artifact. | |
| **Where fine-tuning shines: the edge model.** The effort re-aimed at a **Gemma 4 E4B (~4B)** model that runs on the **free CPU tier** β no paid GPU. Six gated runs, each fixing a diagnosed miss, with schema validity holding at **1.0 the entire time**. On an expanded 60-example eval, the fine-tuned E4B landed at **F1 0.97 / recall 0.96 / clarification 1.0** β an exact statistical tie with stock E4B (identical 48/1/2 true/false/missed), at **zero quality cost** and on a model small enough to run on a laptop. An own, local, parity fine-tune is exactly the "small + well-tuned" sweet spot the constraint rewards. | |
| Two honest notes the eval forced onto the page: | |
| - **Quantization was exonerated.** The same weights, scored at f16 / Q8 / Q4 β precision wasn't the bottleneck. Good to know before chasing a fix that wouldn't have helped. | |
| - **Both the base and the fine-tune are β€ 32B** (31B and ~4B), satisfying the size constraint, and the "no cloud AI" rule applies to the *running app* β dataset prep can use any offline tooling. | |
| ## Off the grid: llama.cpp in a Space | |
| "Local-first" meant: inference runs **in the Space**, not via a hosted LLM API. The build started on ZeroGPU but pivoted to a **Docker Space** built on the official `ggml-org/llama.cpp` image β `llama-server` is always warm, and (crucially) there's no multi-minute build cost compiling llama.cpp from source on the free builders. The big 31B GGUF wants a GPU; the **E4B variant runs on the free CPU tier**, which is the honest "runs on a laptop" story. Running the agents spins up a **second** local `llama-server` for the MiniCPM5-1B planner β still entirely on-box. | |
| The whole point: your messages never touch a third-party AI service. The default output is a file you own. | |
| ## From app to agent: MCP, memory, and shared traces | |
| **It's an MCP server.** Gradio's `mcp_server=True` turns typed functions into tools any MCP-aware client (Claude Desktop, Cursor, β¦) can call β so a larger agent can use scheduling as an **off-grid skill**: | |
| ```python | |
| def extract_events(thread: str, images: list[str] | None = None) -> dict: | |
| """Extract calendar events from a chat thread (and optional screenshots). | |
| Args: | |
| thread: the conversation text to scan for events. | |
| images: optional base64 data-URIs of screenshots. | |
| Returns: an ActionPlan (events, conflicts, proposed_times, reply_draft). | |
| """ | |
| ``` | |
| β¦plus `make_ics(events)` and `check_conflicts(events, ics_base64)`. The MCP schema is generated straight from the type hints and docstrings. | |
| **β¦and it consumes its own tools.** Click **Run the agents** and the loop closes: a local **OpenBMB MiniCPM5-1B** planner drives those same MCP tools as a multi-step agent β `extract_events` β `check_conflicts` β `make_ics` β with every step streamed to the UI. So the Space is *both sides* of MCP at once: it **exposes** scheduling as tools, and it **uses** them, planned by a second small model. Still no cloud AI β the planner is a second local `llama-server`, and if it isn't configured the orchestrator falls back to a deterministic scripted plan, so it works either way. | |
| **Memory that grows with you, on *your* device.** Facts like *"Dana is the soccer coach"* or *"you decline Mondays"* personalize every extraction. They live in your browser's **localStorage** β per-user by default, never on a server β and are injected into the prompt at run time. You can seed them with a 10-second onboarding, or import contacts (`.vcf`/CSV) and a calendar (`.ics`), parsed locally. | |
| **Sharing is Caring.** One click publishes a **redacted** run trace to a public dataset, [`offgridschedula-traces`](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces), for others to learn from. The trace is structural by design β counts and stage names, never your chat text β and redaction is forced for anything public. | |
| ## Built small, by the rules: constraints & badges | |
| Build Small comes with hard rules and a sash of collectable badges. Here's the honest scorecard. | |
| **The hard constraints.** | |
| - **Every model β€ 32B.** Two models, both far under the cap: extraction is **`gemma-cal` E4B** (~4B effective params, ~5 GB at Q4) and planning is **OpenBMB MiniCPM5-1B** (1B). No frontier API anywhere in the loop. | |
| - **A Gradio app on a Hugging Face Space.** A Gradio 6 Blocks UI mounted on a **Docker SDK** Space running llama.cpp. | |
| - **A demo video and a social post, linked from the README.** [βΆ Demo video](https://youtu.be/m-o0u9X3tI4) Β· social posts on [X (1)](https://x.com/nate_mauer/status/2064920352845709419) and [X (2)](https://x.com/nate_mauer/status/2065661878441750916). | |
| - **README frontmatter tags + a short write-up.** Namespaced `track:*` / `sponsor:*` / `achievement:*` tags and the idea-and-tech write-up live at the top of the README. | |
| - **No ZeroGPU sprawl.** Runs on a single dedicated T4 β and, in stub mode, the **free CPU tier** β so there's no ZeroGPU dependency to cap. | |
| **The track and the sponsors.** | |
| - π‘ **Backyard AI** β built for one specific real person (the busy parent), not "users" in the abstract. Short pasted chats and screenshots are exactly a small local model's wheelhouse β an honest fit, not a stretch. | |
| - π’ **Modal** β the whole fine-tune lifecycle ran on Modal serverless GPUs: dataset β QLoRA train β GGUF export β the 60-example eval β the gate that rejected eight regressed models before this one shipped. | |
| - π± **OpenBMB MiniCPM** β the planner behind **Run the agents**, driving the Space's own MCP tools as a visible multi-step agent. | |
| **The six badges β all claimed.** | |
| - π **Off the Grid** β all inference is local llama.cpp; no cloud AI APIs. The only optional outbound call is *your* Google Calendar push. | |
| - π― **Well-Tuned** β [`gemma-cal` E4B](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf), the published QLoRA fine-tune, is the model production serves β shipped through the eval gate with the scorecard public. | |
| - π¨ **Off-Brand** β a bespoke landing page, hero + carousel, grouped nav, and custom results/Activity surfaces, far past the stock Gradio look. | |
| - π¦ **Llama Champion** β the official `ggml-org/llama.cpp` server image runs the GGUF + vision mmproj. | |
| - π‘ **Sharing is Caring** β one click publishes a **redacted** run trace to the public [`offgridschedula-traces`](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces) dataset. | |
| - π **Field Notes** β [this post](https://huggingface.co/blog/build-small-hackathon/offgridschedula), plus `FIELD_NOTES.md` (the build retrospective) and the eval-gated fine-tuning post-mortem. | |
| Two honest footnotes the rules deserve: E4B is a MatFormer **"effective-4B"** (judges' call on whether that's *tiny* enough for the Tiny Titan nod), and the "no cloud AI" rule applies to the **running app** β the offline dataset prep and training on Modal are fair game. | |
| ## Lessons learned | |
| A field-notes dump of things that cost time here so they don't cost yours: | |
| > **Gradio 6 ignores `css=`/`js=` set on `gr.Blocks` when it's mounted under FastAPI.** Custom styling silently doesn't apply. The fix that stuck: inject the CSS at mount time and the JS as a real inline `<script>` before `` via middleware, so it always executes: | |
| > ```python | |
| > html = html.replace("", f"({CAROUSEL_JS})()</script></body>") | |
| > ``` | |
| > **`display:none` β "responsive."** Gradio's tab strip needed explicit CSS to hide on narrow screens; the nav buttons click the *real* hidden Gradio tabs underneath. A `MutationObserver` re-wires everything after each Gradio swap so dynamic updates don't break it. | |
| > **Make the expensive dependency optional on day one.** The stub extractor + lazy imports meant the same codebase is the test harness, the offline demo, and the free tier β all at once. | |
| > **The HF MCP badge is Gradio-SDK only.** A pure FastAPI app or a bare `llama-server` won't advertise MCP; you need Gradio's `mcp_server=True` and typed, docstringed tool functions. | |
| > **Traces are redacted by construction, not after the fact.** The activity bus only ever emits counts and short status strings; the one free-text field (a chat name) is stripped. An allowlist beats a denylist when the cost of a leak is someone's private calendar. | |
| > **Be honest about "off the grid."** A 31B GGUF (~18β20 GB) wants a GPU β and "your own cloud GPU" is easy to conflate with "a cloud AI API." The headline model stayed honest, and the E4B edge variant shipped as the truly-local path. | |
| And the meta-lesson: **an eval that gates publishing changes how you work.** Once "does it beat the bar?" is automated, you stop arguing about vibes and start shipping only what's measurably better β or, as happened here, a strong prompt on a capable base turns out to be the right answer sometimes, and the fine-tune is kept for where it actually wins. | |
| ## What's next | |
| - Polish the optional Mac collector (reading `~/Library/Messages/chat.db`, including the `attributedBody` blobs modern Messages uses). | |
| - Grow the on-device memory beyond contacts and preferences. | |
| - Push the E4B edge model further so the fully-local path matches the 31B headline on the hardest relative-date cases. | |
| ## Get started | |
| - **Try it (30 seconds, no install):** open the [Space](https://huggingface.co/spaces/build-small-hackathon/OffGridSchedula), tap **Try a sample**, and watch a chat become events + a conflict check + a reply. | |
| - **The model:** [`build-small-hackathon/gemma-4-cal-gguf`](https://huggingface.co/build-small-hackathon/gemma-4-cal-gguf) (QLoRA fine-tune of Gemma 4, GGUF for llama.cpp). | |
| - **The planner:** [`openbmb/MiniCPM5-1B-GGUF`](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) β drives the multi-step orchestration behind **Run the agents**, locally via llama.cpp. | |
| - **The traces dataset:** [`ParetoOptimal/offgridschedula-traces`](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces). | |
| - **Use it as a tool:** add the Space as an MCP server and call `extract_events` from your own agent. | |
| ## Acknowledgements & citation | |
| Built on the shoulders of [Gemma 4](https://huggingface.co/google), [OpenBMB MiniCPM](https://huggingface.co/openbmb), [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp), [Gradio](https://www.gradio.app/), and [Unsloth](https://github.com/unslothai/unsloth). Training data uses **SMCalFlow** (Semantic Machines et al., *"Task-Oriented Dialogue as Dataflow Synthesis,"* TACL 2020; CC BY-SA 4.0). | |
| ```bibtex | |
| @misc{offgridschedula2026, | |
| title = {OffGridSchedula: a local-first, fine-tuned scheduling agent}, | |
| author = {ParetoOptimal}, | |
| year = {2026}, | |
| howpublished = {\url{https://huggingface.co/blog/build-small-hackathon/offgridschedula}} | |
| } | |
| ``` | |