Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- app.py +4 -19
- docs/OffGridSchedula β In-Depth Technical Talk Track.md +119 -0
- docs/blog-eval-gated-finetuning.md +187 -0
- docs/blog-offgridschedula.md +211 -0
- docs/demo-script.md +34 -0
- docs/demo-walkthrough.md +127 -0
- server/orchestrator.py +7 -3
- static/app.css +0 -15
- ui/blocks.py +2 -6
app.py
CHANGED
|
@@ -11,7 +11,7 @@ from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request
|
|
| 11 |
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
| 12 |
from pydantic import BaseModel
|
| 13 |
|
| 14 |
-
from server import dedup, events,
|
| 15 |
from server.pipeline import AgentRequest, AgentResponse, run_pipeline
|
| 16 |
from ui.blocks import CAROUSEL_JS, CSS, THEME, build_demo
|
| 17 |
|
|
@@ -134,10 +134,8 @@ def ingest(batch: IngestBatch, background_tasks: BackgroundTasks,
|
|
| 134 |
|
| 135 |
|
| 136 |
@app.get("/health")
|
| 137 |
-
def
|
| 138 |
-
|
| 139 |
-
# status banner and the maintenance monitor both read this.
|
| 140 |
-
return health.health_status()
|
| 141 |
|
| 142 |
|
| 143 |
# --- Per-user Google Calendar OAuth (web flow) ----------------------------- #
|
|
@@ -254,19 +252,6 @@ if SERVE == "uvicorn":
|
|
| 254 |
from starlette.responses import Response as _Response
|
| 255 |
|
| 256 |
_CAROUSEL_INLINE = f'<script id="cz-inline-js">({CAROUSEL_JS})();</script>'
|
| 257 |
-
# Status banner: fetch /health on load and reveal #status-banner if degraded
|
| 258 |
-
# (e.g. real model on CPU-only hardware). Same inline-script pattern as the
|
| 259 |
-
# carousel, since js= is unreliable on a mounted app; it polls for the element
|
| 260 |
-
# because Gradio renders it client-side after </body>.
|
| 261 |
-
_BANNER_JS = (
|
| 262 |
-
"(function(){fetch('/health').then(function(r){return r.json();})"
|
| 263 |
-
".then(function(h){if(!h||!h.degraded){return;}(function s(){"
|
| 264 |
-
"var b=document.getElementById('status-banner');"
|
| 265 |
-
"if(!b){return setTimeout(s,400);}"
|
| 266 |
-
"b.textContent='\\u26a0\\ufe0f '+(h.reason||'This Space needs a GPU.')+' \\u26a0\\ufe0f';"
|
| 267 |
-
"b.style.display='block';})();}).catch(function(){});})();"
|
| 268 |
-
)
|
| 269 |
-
_BANNER_INLINE = f'<script id="cz-banner-js">{_BANNER_JS}</script>'
|
| 270 |
|
| 271 |
@app.middleware("http")
|
| 272 |
async def _inject_carousel_js(request, call_next): # noqa: ANN001
|
|
@@ -276,7 +261,7 @@ if SERVE == "uvicorn":
|
|
| 276 |
body = b"".join([chunk async for chunk in resp.body_iterator])
|
| 277 |
html = body.decode("utf-8", "ignore")
|
| 278 |
if "cz-inline-js" not in html and "</body>" in html:
|
| 279 |
-
html = html.replace("</body>", _CAROUSEL_INLINE +
|
| 280 |
headers = dict(resp.headers)
|
| 281 |
headers.pop("content-length", None) # body length changed; let Starlette recompute
|
| 282 |
return _Response(content=html, status_code=resp.status_code,
|
|
|
|
| 11 |
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
| 12 |
from pydantic import BaseModel
|
| 13 |
|
| 14 |
+
from server import dedup, events, threads
|
| 15 |
from server.pipeline import AgentRequest, AgentResponse, run_pipeline
|
| 16 |
from ui.blocks import CAROUSEL_JS, CSS, THEME, build_demo
|
| 17 |
|
|
|
|
| 134 |
|
| 135 |
|
| 136 |
@app.get("/health")
|
| 137 |
+
def health():
|
| 138 |
+
return {"ok": True}
|
|
|
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
# --- Per-user Google Calendar OAuth (web flow) ----------------------------- #
|
|
|
|
| 252 |
from starlette.responses import Response as _Response
|
| 253 |
|
| 254 |
_CAROUSEL_INLINE = f'<script id="cz-inline-js">({CAROUSEL_JS})();</script>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
@app.middleware("http")
|
| 257 |
async def _inject_carousel_js(request, call_next): # noqa: ANN001
|
|
|
|
| 261 |
body = b"".join([chunk async for chunk in resp.body_iterator])
|
| 262 |
html = body.decode("utf-8", "ignore")
|
| 263 |
if "cz-inline-js" not in html and "</body>" in html:
|
| 264 |
+
html = html.replace("</body>", _CAROUSEL_INLINE + "</body>", 1)
|
| 265 |
headers = dict(resp.headers)
|
| 266 |
headers.pop("content-length", None) # body length changed; let Starlette recompute
|
| 267 |
return _Response(content=html, status_code=resp.status_code,
|
docs/OffGridSchedula β In-Depth Technical Talk Track.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OffGridSchedula β In-Depth Technical Talk Track
|
| 2 |
+
*Audience: CS graduate students. Framing: a small, locally-served fine-tuned model wrapped in deliberate systems engineering.*
|
| 3 |
+
|
| 4 |
+
> πΈ **Screenshot suggestion (hero):** the landing/Home hero with the tagline and the badge row (π 100% local Β· π¦ llama.cpp Β· π― Fine-tuned Β· πͺΆ β€ 32B), establishing the "small, local, fine-tuned" thesis up front.
|
| 5 |
+
|
| 6 |
+
## [Opener β ~20s]
|
| 7 |
+
OffGridSchedula reads a messy group chat and produces calendar events β entirely on a ~4-billion-parameter model running locally, with no cloud AI in the loop. What I want to show you isn't just *what* each page does, but the engineering decisions that make a small model behave reliably. The recurring theme: **use the LLM only for the fuzzy natural-language-to-structure step, and make everything that can be deterministic, deterministic.**
|
| 8 |
+
|
| 9 |
+
## [The model-type decision β why decoders, not encoders β ~1:15]
|
| 10 |
+
> πΈ **Screenshot suggestion:** a simple two-box slide β *encoder (BERT): classify Β· tag Β· embed* on the left, *decoder (GPT-style): generate Β· reason Β· orchestrate* on the right β with the app's `ActionPlan` JSON landing under the decoder side. If you have the architecture inset (the two local llama-servers, gemma-cal + MiniCPM), show it here to preview the two-decoder design.
|
| 11 |
+
|
| 12 |
+
**The framing.** Before *which* model β before size, fine-tuning, or quantization β there's a more fundamental fork most teams skip past: *encoder or decoder?* We made that call twice, once for extraction and once for orchestration, and both times the answer was a decoder. It's worth making the reasoning explicit, because it's the decision the rest of the system is built on top of.
|
| 13 |
+
|
| 14 |
+
**The decisions behind it:**
|
| 15 |
+
|
| 16 |
+
- **Why a decoder, not BERT β the deliverable decides.** Per thread the extractor emits one constrained object: `events[]` (title, ISO-8601 start/end, location, reminders), a `reply_draft`, and a `needs_clarification` question when the thread is ambiguous. That isn't classification and it isn't span-tagging β it's *generation* plus reasoning, resolving "next Tuesday" against a reference date and shifting a start when someone says "arrive 15 min early." Encoder-only models (BERT / RoBERTa / DeBERTa) are *discriminative*: they understand β classify, tag, embed β but they don't *write*. To force this onto BERT you'd stitch together an intent classifier + an NER tagger + a datetime normalizer, and you still couldn't draft the reply. A decoder wasn't the better option here; it was the only candidate.
|
| 17 |
+
|
| 18 |
+
- **Why orchestration is *also* a decoder job.** Planning is generative reasoning too β you can't classify your way to "call `extract_events`, observe the result, then finalize." So the Agent tab's planner is a *second* decoder, **OpenBMB's MiniCPM**, deliberately separate from the extractor: MiniCPM decides *which tools to call*, the fine-tuned E4B does the extraction, each model doing the one thing it's good at. (The MiniCPM-over-MCP design gets its own walkthrough on the Agent page β the point here is simply that *both* roles an encoder fundamentally can't fill, generating structure and planning tool calls, are decoder territory.)
|
| 19 |
+
|
| 20 |
+
- **The tradeoff we accepted, stated honestly.** Decoders cost you. Generation is autoregressive, so latency scales with output length where a BERT tagger would answer in <100 ms; you need a GPU (~5 GB VRAM at 4-bit) where an encoder runs on CPU for cents; and it's heavier to deploy β GGUFs through llama.cpp versus a single CPU model. We took all of it, because the work is generative and there's no honest way around that.
|
| 21 |
+
|
| 22 |
+
- **Why we could give up BERT's safety and still be safe.** An encoder has one quiet superpower: it *can't* hallucinate a label set it was never given. A decoder gives that guarantee up β so we rebuilt it in the systems layer instead of the model. Generation is grammar-constrained to a JSON Schema derived from our Pydantic `ActionPlan` (correct by construction); everything that must be exact stays in deterministic Python; and an eval gate (schema-validity, precision, recall, F1) has *rejected* several of our own fine-tunes before they could ship. That's the recurring theme applied to model *choice*: pick the model that can do the fuzzy job, then wrap it so the exact parts are guaranteed in code.
|
| 23 |
+
|
| 24 |
+
- **When the encoder still earns its place.** Choosing a decoder for the core doesn't retire BERT β it relocates it to the *edges*. The efficient extension is a cheap encoder front-door: a tiny CPU classifier ("is this even a scheduling message?") that gates the expensive GPU decode, plus sentence-embeddings for the Memory tab's recall. Encoders to *decide and retrieve*; decoders to *generate and orchestrate*; route cheap β expensive.
|
| 25 |
+
|
| 26 |
+
**One-line bridge:** type settled β a decoder, in fact two β the next questions are *how small* and *how specialized*, which is exactly where the Schedule page picks up.
|
| 27 |
+
|
| 28 |
+
## [Home β the Schedule page β ~2:00]
|
| 29 |
+
> πΈ **Screenshot suggestion:** the Schedule tab mid-run β a pasted appointment thread on the left, the streamed results on the right showing an extracted event (e.g. 10:15β11:00 with the joined address), a β οΈ conflict badge, and the drafted reply. A second shot of the live "thinking" trace streaming is a nice optional inset.
|
| 30 |
+
|
| 31 |
+
**Feature.** You paste a chat thread or drop a screenshot; the app streams back extracted events, a conflict check against your calendar, a ready-to-send reply, and one-tap export to `.ics` or Google/Outlook/Apple.
|
| 32 |
+
|
| 33 |
+
**The decisions behind it:**
|
| 34 |
+
|
| 35 |
+
- **Why a fine-tuned *small* model, not a frontier API.** The hard constraint was local-first β no cloud AI calls. So the model has to be edge-sized: *gemma-cal*, a QLoRA fine-tune of Gemma E4B, ~5 GB at 4-bit, served as a GGUF through llama.cpp. The decision to *fine-tune* rather than prompt a generic model wasn't about raw capability β it was about **reliability of structure**. The fine-tune holds 100% schema validity even with no system prompt, won't invent an event from "thanks!", and learns *this product's* date conventions (what "next Tuesday" resolves to). You trade general intelligence for a model that's bulletproof at one job.
|
| 36 |
+
|
| 37 |
+
- **Why grammar-constrained decoding.** The single most important reliability decision. Generation is constrained to a JSON Schema derived directly from our Pydantic model (`json_schema=ActionPlan.model_json_schema()`). The decoder can only emit tokens that keep the output a valid `ActionPlan`. Compare the alternative β generate free text and parse-and-repair β which is exactly where small models fall over. By constraining at the grammar level, we delete an entire class of failures: no malformed JSON, no missing fields, no hallucinated structure. The contract is enforced at decode time, not hoped for afterward.
|
| 38 |
+
|
| 39 |
+
- **Why one `ActionPlan` object.** The model emits a single structured plan β `reasoning`, `events[]`, `conflicts[]`, `proposed_times[]`, `reply_draft`, `needs_clarification` β in one pass. Two design choices live here: first, everything downstream is *typed* (Pydantic), so the UI and exporters never touch raw model text. Second, `needs_clarification` is a first-class field β when a thread is ambiguous ("sometime next week"), the model is trained to *ask* instead of fabricating a datetime. That's a deliberate choice to fail safe rather than fail confident.
|
| 40 |
+
|
| 41 |
+
- **Why conflict detection is NOT the model.** Interval overlap is exact, cheap, and verifiable β so we don't spend LLM tokens or risk hallucination on it. `calendar_out/freebusy.py` does deterministic free/busy math against intervals parsed from your `.ics`, and classifies each clash as overlap, adjacent, or tight. The principle: **the LLM converts language to structure; Python does the temporal logic.** Same reasoning drove the deterministic post-processing for arrival-aware start times and type-based reminders β logistics that *must* hold get guaranteed in code, with the model's output as the starting point.
|
| 42 |
+
|
| 43 |
+
- **Why streaming, and why a browser-held OAuth token.** A local model is slower than an API, so we stream the reasoning trace to make the wait legible. And the Google token lives in *your browser's* localStorage, never on the server β the server never holds your calendar credentials. That's a privacy decision that also keeps the "off-grid" claim honest: the only outbound call is the user's own optional calendar push.
|
| 44 |
+
|
| 45 |
+
## [Agent page β the MiniCPM-over-MCP design β ~2:00]
|
| 46 |
+
> πΈ **Screenshot suggestion:** the Agent tab's live step trace β "MiniCPM planner engaged β tool_call: extract_events β observation β Planner finished" β so the audience sees a model genuinely planning and calling a tool. Pair it with a tiny inset of the architecture (two local llama-servers: gemma-cal + MiniCPM) if you have a diagram.
|
| 47 |
+
|
| 48 |
+
**Feature.** The Agent tab runs the *same* extraction workflow, but agentically: a separate planner model reads the thread, calls the app's tools itself, and you watch every step β plan, tool call, observation, result β before it lands on the same events/conflicts/reply surface as Home.
|
| 49 |
+
|
| 50 |
+
**The decisions behind it:**
|
| 51 |
+
|
| 52 |
+
- **Why expose the app's own functions as MCP tools β and have the agent eat its own dog food.** `extract_events`, `check_conflicts`, and `make_ics` are published as **Model Context Protocol tools** via Gradio's `mcp_server=True`, served over an SSE endpoint. The deliberate choice is that the planner consumes them through that *public* MCP contract β the same interface an external client like Claude Desktop or Cursor would use β not via private Python imports. So the agentic path is a genuine proof that the Space is a real MCP server, not a demo wrapper. The agent talks to the application exactly the way a third party would.
|
| 53 |
+
|
| 54 |
+
- **Why a second, separate model just for planning.** The fine-tuned E4B is a *specialist* β natural-language-to-`ActionPlan`. Orchestration is a different skill, so it's a different model: **OpenBMB's MiniCPM (1B), on its own local llama-server**. That's a clean separation of concerns β the planner decides *which tools to call*; the extractor does the extraction β and both stay local, both under the size cap, still zero cloud AI. It's a two-model architecture where each model does the thing it's good at.
|
| 55 |
+
|
| 56 |
+
- **Why a framework + a standard, not a bespoke loop.** Rather than hand-roll an agent, we use smolagents' `ToolCallingAgent` pointed at MiniCPM (an OpenAI-compatible server model) over an `MCPClient` connected to the localhost SSE endpoint. Standing on the agent framework plus the MCP standard means the interesting engineering is in the *integration*, not in reinventing tool-call parsing.
|
| 57 |
+
|
| 58 |
+
- **Why the planner is now a *single* tool call β the central lesson.** This is the part worth dwelling on, because we got here empirically. The first design was a multi-step planner: extract β check conflicts β render `.ics`. The problem is structural to agent loops: **every step re-sends the accumulated context** β the task, the thread, and each prior tool's full observation. On long, multi-event threads that did two bad things: it blew past the planner's context window (a hard `request exceeds context` failure), and it ballooned latency to ~3.5 minutes. The insight that fixed it: conflict math and `.ics` rendering are *already deterministic* β the planner never needed to drive them. So we collapsed the planner to **exactly one tool call (`extract_events`)**, then let the deterministic path finalize the rest. The agentic value β a model genuinely planning and invoking a tool through MCP β is fully preserved, but the unbounded-accumulation pathology is gone. Latency dropped from ~207s to ~75s, and context overflow became *structurally impossible*, because a single call has nothing to accumulate.
|
| 59 |
+
|
| 60 |
+
- **Why a deterministic scripted fallback wraps the whole thing.** If the planner is unavailable β model down, an error mid-run, or CI/stub mode with no model β `run_orchestrator` degrades to a scripted sequence that runs the same tools deterministically and emits *identical* step events. The decision: the tab must always return a correct result, so the LLM planner is the showcase, never a single point of failure. As a bonus, that deterministic path is exactly what the test suite exercises β CI never needs a GPU.
|
| 61 |
+
|
| 62 |
+
- **The unglamorous interop decisions that actually made small-model tool use work.** Three of them, because they're the reality of this space. First, Gradio exposes *every* UI callback as an MCP tool β ~64 of them β so we **filter the planner's toolset down to the calendar tools**; handing a 1B model 64 tools degrades planning and, worse, pulls in file-input schemas with unresolvable `$ref`s that crash prompt rendering. Second, we had to register `make_ics`/`check_conflicts` through `gr.api()` rather than hidden components, because Gradio otherwise emitted an *untyped* JSON schema (`"type": {}`) that the small planner filled with a malformed argument. Third, the extraction tool is text-only over MCP for the same typing reason. None of this is glamorous, but it's the difference between "the agent works in the slides" and "the agent works."
|
| 63 |
+
|
| 64 |
+
- **Why the planner's answer isn't the product.** The planner's free-text output is treated as *evidence* β the visible trace that proves the agent reasoned and acted. The canonical `ActionPlan` is still re-derived through the deterministic pipeline (and an extraction cache means the planner's call and the finalization share a single E4B inference). It's a trust-but-verify architecture: the agent shows its work for transparency, the deterministic core guarantees the result.
|
| 65 |
+
|
| 66 |
+
**One-line bridge back to the theme:** even the *agentic* page obeys the project's core rule β the model plans and calls a tool, but the exact, must-be-correct work stays in deterministic code.
|
| 67 |
+
|
| 68 |
+
## [Activity page β ~1:30]
|
| 69 |
+
> πΈ **Screenshot suggestion:** the Activity dashboard β the colored stepper, the metric tiles (events, conflicts, latency), the stage histogram (BarPlot), and the "this week" impact panel β all on screen at once. An optional second shot: the one-click "publish redacted trace" confirmation linking to the public HF dataset.
|
| 70 |
+
|
| 71 |
+
**Feature.** A live operations dashboard: a real-time stepper, latency/throughput tiles, an event timeline, a stage histogram, recent per-run traces, and a durable "this week" impact panel β refreshed every two seconds.
|
| 72 |
+
|
| 73 |
+
**The decisions behind it:**
|
| 74 |
+
|
| 75 |
+
- **Why an in-process event bus.** Instrumentation is decoupled from logic via `server/events.py`: the pipeline calls `bus.emit(stage, message, β¦)` and the UI subscribes. The decision to keep it an **ephemeral ring buffer** rather than a database is deliberate β live telemetry doesn't need durability, and a Space shouldn't carry a DB dependency for a dashboard.
|
| 76 |
+
|
| 77 |
+
- **Why a second, *durable* tier.** The weekly impact (events captured, conflicts caught, minutes saved) *does* need to survive restarts, so it's a separate persistent log (`server/impact.py`). Two-tier telemetry: ephemeral for "what's happening now," durable for "what value accrued." And `minutes_saved` is explicitly a *configurable estimate*, not a measurement β an honesty decision, surfaced as such.
|
| 78 |
+
|
| 79 |
+
- **Why contextvars for run scoping.** Correlating emitted events to a single run is hard when Gradio streams across async contexts. We use a `run_scope` contextvar to tag events, and β this is the gotcha β wrap its reset in try/except because Gradio resets contexts underneath us. That's the kind of framework-reality decision you only make after it breaks once.
|
| 80 |
+
|
| 81 |
+
- **Why publishable, redacted traces.** Any run's trace can be pushed to a public Hugging Face dataset in one click β for reproducibility and community learning. The critical decision: traces are **structural by design** β stage names, counts, latencies, *never* the chat text β and redaction is forced for anything public. Privacy-by-construction: the thing you can share literally cannot contain the thing you must protect.
|
| 82 |
+
|
| 83 |
+
## [Memory page β ~1:15]
|
| 84 |
+
> πΈ **Screenshot suggestion:** the Memory tab dataframe showing a few sample facts (people/roles/preferences with their weights) and the "load sample memories" button β and, if you can, a before/after of an extraction that resolves "Coach" to the right person once a fact is present.
|
| 85 |
+
|
| 86 |
+
**Feature.** Device-owned facts β people, roles, preferences, locations β that personalize extraction so the model resolves "Coach" or "the dentist" to the right person, and learns your recurring contacts.
|
| 87 |
+
|
| 88 |
+
**The decisions behind it:**
|
| 89 |
+
|
| 90 |
+
- **Why localStorage, not a server database.** Personalization data is the most sensitive data in the system, so it never touches the server. Facts live in the browser; on each request the UI passes a compact `memory_block` to the extractor. Two payoffs: there's no PII at rest on a shared Space, and it's inherently multi-user-safe because there's no shared server state to collide. The decision treats memory as *the user's*, not the app's.
|
| 91 |
+
|
| 92 |
+
- **Why prompt-injection instead of per-user fine-tuning.** `facts_to_recall()` renders the facts into a compact "what I know about you" block prepended to the extraction prompt. Per-user fine-tuning would be the heavyweight answer and is infeasible at this scale; prompt-time personalization is instant, cheap, and reversible β essentially a tiny, hand-curated RAG. You get personalization without touching the weights.
|
| 93 |
+
|
| 94 |
+
- **Why weighted facts and auto-observation.** Each fact carries a weight that's bumped on reinforcement β a lightweight recency/relevance signal so repeated facts win, with no model involved. And `observe_plan()` quietly learns recurring attendees from accepted plans, so the system "grows with you" without demanding explicit input. There's also a deliberate split: the UI path uses client memory, while a separate server-side recall exists for the autonomous path β kept apart so per-user data never pollutes the shared store.
|
| 95 |
+
|
| 96 |
+
## [Feed page β ~1:00]
|
| 97 |
+
> πΈ **Screenshot suggestion:** the privacy-sealed Feed on the public build β i.e. the `{"feed": "private", β¦}` notice β to make the privacy-by-default point visually. Optionally, a paired shot of the populated JSON view on a trusted local deployment to show what the Mac collector ingests.
|
| 98 |
+
|
| 99 |
+
**Feature.** A JSON view of raw iMessages ingested from an optional Mac collector β for the hands-off, autonomous mode.
|
| 100 |
+
|
| 101 |
+
**The decisions behind it:**
|
| 102 |
+
|
| 103 |
+
- **Why a collector at all β a platform-forced decision.** iOS exposes no API for iMessage content. There's no clean way to read messages on the phone. So the architecture works *around* the constraint: a small collector on a Mac (where iMessages sync) reads `~/Library/Messages/chat.db` with Full Disk Access and POSTs new messages to a FastAPI `/ingest` endpoint. This is a case study in letting a hard platform limit dictate the system boundary β the primary path stays "paste from your phone browser, no install," and the collector is an optional power-user add-on.
|
| 104 |
+
|
| 105 |
+
- **Why `/ingest` is token-authenticated.** The endpoint accepts message *content*, so it's bearer-token gated (`INGEST_TOKEN`) β an unauthenticated write path for personal messages would be indefensible.
|
| 106 |
+
|
| 107 |
+
- **Why the Feed is privacy-gated by default.** Raw message text is the most sensitive thing the app ever sees, so the Feed only renders it when `EXPOSE_FEED=1` β on public builds it returns a privacy notice instead. The default is sealed; you opt *in* to visibility on a trusted local deployment, never *out* on a public one. Privacy-by-default, again, as a hard switch rather than a guideline.
|
| 108 |
+
|
| 109 |
+
## [Submission page β ~50s]
|
| 110 |
+
> πΈ **Screenshot suggestion:** the Submission tab's green-checked scorecard β hard rules (β€ 32B, Gradio on HF, public repo) and the bonus quests β with the evidence links visible, to drive home the "self-verifying compliance" point.
|
| 111 |
+
|
| 112 |
+
**Feature.** A live compliance scorecard for the hackathon β green checks against the rules, with evidence links to the model, training scripts, and source.
|
| 113 |
+
|
| 114 |
+
**The decisions behind it:**
|
| 115 |
+
|
| 116 |
+
- **Why generate it from the running environment, not hardcode it.** `_compliance_facts()` reads the *live* config β `MODEL_REPO`, `MODEL_FILE`, `INFERENCE_BASE_URL` β and derives the claims from what's actually loaded. If you swap the model or point inference at a remote server, the scorecard changes with it. The decision was to make compliance **self-verifying and tamper-evident**: the page can't claim "under 32B, local llama.cpp, published fine-tune" unless that's the truth of the process serving the page. For a CS audience, it's reproducibility applied to a submission β the artifact attests to its own provenance.
|
| 117 |
+
|
| 118 |
+
## [Close β ~20s]
|
| 119 |
+
Step back and the pattern is consistent: a **fine-tune for structural reliability**, **grammar-constrained decoding** so output is correct by construction, **deterministic code for everything exact**, **privacy-by-default** at every data boundary, and **self-attesting compliance**. The headline is "a small model runs your scheduling" β but the engineering story is everything built *around* that model to make it trustworthy.
|
docs/blog-eval-gated-finetuning.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# What Six Failed Fine-Tunes Taught Us About Evals, Templates, and Knowing When to Stop
|
| 2 |
+
|
| 3 |
+
*A post-mortem on fine-tuning Gemma-4 for structured calendar extraction β fifteen GPU runs,
|
| 4 |
+
one destroyed model, one exonerated quantizer, a chat-template landmine, and the eval harness
|
| 5 |
+
that caught every bad model before it shipped.*
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## The setup
|
| 10 |
+
|
| 11 |
+
[OffGridSchedula](https://huggingface.co/spaces/ParetoOptimal/OffGridSchedula) is a local-first
|
| 12 |
+
scheduling agent: paste a group chat (or a flyer screenshot) and get back a constrained
|
| 13 |
+
**ActionPlan** JSON β events with exact ISO datetimes, a conflict check, a drafted reply, and a
|
| 14 |
+
`needs_clarification` question when the thread is too vague to schedule. Inference is
|
| 15 |
+
llama.cpp serving Gemma-4 GGUFs; no cloud AI APIs.
|
| 16 |
+
|
| 17 |
+
The project carried a hard requirement: ship a **fine-tuned model** that outperforms its base.
|
| 18 |
+
This is the story of trying to satisfy that requirement honestly β and what "honestly" ended up
|
| 19 |
+
costing and teaching. Everything below ran on Modal serverless A100s; total GPU spend for the
|
| 20 |
+
entire investigation was well under $100.
|
| 21 |
+
|
| 22 |
+
## Act I: The fine-tune that lost to its own base
|
| 23 |
+
|
| 24 |
+
The first QLoRA fine-tune of `google/gemma-4-31B-it` (Unsloth, r=16, 69 synthetic examples,
|
| 25 |
+
2 epochs) looked fine in a smoke test. So we built a real eval before trusting it: 28 held-out
|
| 26 |
+
examples scored on **start-exact recall** (did you produce the exact ISO start datetime),
|
| 27 |
+
event F1 with greedy datetime matching, schema validity, no-event accuracy (does chitchat
|
| 28 |
+
hallucinate events), and clarification recall (do you *ask* instead of inventing when a plan is
|
| 29 |
+
"TBD"). Temperature 0, the same `response_format: json_schema` call the production server uses.
|
| 30 |
+
|
| 31 |
+
First scores: fine-tune **F1 0.81**, base **0.977**. The fine-tune *lost to its own base*. The
|
| 32 |
+
mismatch dump showed why β three of its five misses were the same corruption: `"206-10-06"`
|
| 33 |
+
instead of `"2026-10-06"`. A dropped year digit.
|
| 34 |
+
|
| 35 |
+
Two suspects: quantization (classic low-bit digit corruption) or the training itself.
|
| 36 |
+
|
| 37 |
+
## Act II: Scaling data made it worse. Much worse.
|
| 38 |
+
|
| 39 |
+
The intuitive fix β more data β backfired in the most instructive way possible:
|
| 40 |
+
|
| 41 |
+
| training examples | schema validity | event F1 |
|
| 42 |
+
|---|---|---|
|
| 43 |
+
| 69 | 1.00 | 0.81 |
|
| 44 |
+
| 87 | 0.75 | 0.465 |
|
| 45 |
+
| 122 | 0.46 | 0.214 |
|
| 46 |
+
| 2,122 (incl. real SMCalFlow data) | 0.107 | 0.000 |
|
| 47 |
+
|
| 48 |
+
**Monotonic decay with training steps.** By the 2,122-example run the model emitted unparseable
|
| 49 |
+
output on ~90% of inputs. A raw-output probe (serve the staging GGUF, generate *without* the
|
| 50 |
+
JSON grammar) settled what "broken" meant: the model free-generated `Huddle β β β β β ββ¦` to the
|
| 51 |
+
token limit. Not a formatting problem. Destroyed weights.
|
| 52 |
+
|
| 53 |
+
Two cheap experiments isolated the cause:
|
| 54 |
+
|
| 55 |
+
**Quantization was exonerated** by sweeping the *same* merged weights through f16 / Q8_0 /
|
| 56 |
+
Q4_K_M (one A100 lease, the fp16 was already on a Modal volume). At full fp16 the fine-tune
|
| 57 |
+
still scored validity 0.64 / F1 0.57 β nowhere near base. Precision bought ~+0.1 F1. The damage
|
| 58 |
+
preceded the quantizer.
|
| 59 |
+
|
| 60 |
+
**The chat template was half the story.** Gemma-4 ships a brand-new template β
|
| 61 |
+
`<|turn>user\nβ¦<turn|>`, with a dedicated `<|turn>system` block. There is no
|
| 62 |
+
`<start_of_turn>` anywhere in it. Our training code used Unsloth's legacy `"gemma"` template,
|
| 63 |
+
which is built entirely on `<start_of_turn>`. Every gradient step optimized a turn syntax that
|
| 64 |
+
`llama-server --jinja` (which reads the template *embedded in the GGUF*) never renders. We
|
| 65 |
+
verified the fix end-to-end by reading `tokenizer.chat_template` out of our exported GGUF's
|
| 66 |
+
metadata with `gguf.GGUFReader` β trust the artifact, not the code β and added a hard
|
| 67 |
+
`assert "<|turn>" in rendered` to the training script so the mismatch can never silently
|
| 68 |
+
recur.
|
| 69 |
+
|
| 70 |
+
And yet: with templates verifiably aligned, response-only loss masking, and LR dropped to 5e-5,
|
| 71 |
+
the 31B *still* collapsed to validity 0.0. With dataset, template, LR, and masking all varied,
|
| 72 |
+
the one remaining common factor was the training stack itself: Unsloth's QLoRA path for the
|
| 73 |
+
brand-new Gemma-4-31B architecture (its own logs warn it can't handle `Gemma4AudioModel`
|
| 74 |
+
internals). The same recipe on Gemma-4 **E4B** trained cleanly every single time. New
|
| 75 |
+
architectures make the training framework a first-class suspect.
|
| 76 |
+
|
| 77 |
+
## Act III: The benchmark nobody wants β prompt engineering hits 1.0
|
| 78 |
+
|
| 79 |
+
While the fine-tune investigation ran, error analysis kept improving the *system*:
|
| 80 |
+
|
| 81 |
+
- **State the weekday in the prompt.** `Current datetime: Monday, 2026-09-14T09:00:00` turns
|
| 82 |
+
day-of-week resolution from memorized calendar knowledge into deterministic arithmetic β for
|
| 83 |
+
every model.
|
| 84 |
+
- **Two surgical system-prompt lines** targeting the base's only two eval misses (multi-event
|
| 85 |
+
splitting; asking on "TBD") took **stock Gemma-4-31B to 1.0 on every metric**.
|
| 86 |
+
|
| 87 |
+
That's the uncomfortable benchmark for any SFT project: against a near-ceiling base, prompt
|
| 88 |
+
engineering had ~100Γ better ROI than fine-tuning. The requirement, however, was a fine-tune
|
| 89 |
+
that beats *its* base β so we re-aimed at the tier where headroom actually existed.
|
| 90 |
+
|
| 91 |
+
## Act IV: The E4B campaign β six gated runs to a tie
|
| 92 |
+
|
| 93 |
+
A ~5 GB Gemma-4 E4B that runs on modest hardware is the model this local-first project actually
|
| 94 |
+
wants at the edge, and stock E4B had real room: F1 0.93. Every retrain ran through an
|
| 95 |
+
**eval-gate**: train β upload to a *staging* filename β eval β promote to production **only if
|
| 96 |
+
it beats the bar**, else delete staging. The gate rejected eight models across this project
|
| 97 |
+
without production ever serving one of them.
|
| 98 |
+
|
| 99 |
+
Each iteration fixed a diagnosed failure, not a hunch:
|
| 100 |
+
|
| 101 |
+
| run | change | F1 (eval) |
|
| 102 |
+
|---|---|---|
|
| 103 |
+
| 1 | fixed recipe, 2,122 examples | 0.884 (n=28) |
|
| 104 |
+
| 2 | weekday-in-prompt, data regenerated to match | 0.955 |
|
| 105 |
+
| 3 | dropped 74 SMCalFlow rows teaching a conflicting "next DOW" convention; 4Γ hand-data upsample | **1.000** |
|
| 106 |
+
| 4 | + TBD-clarify seeds, 8Γ upsample | 0.93 (clarify β 1.0) |
|
| 107 |
+
| 5 | clarify seeds at 4Γ | 0.93 |
|
| 108 |
+
| β | **eval expanded 28 β 60 examples** | β |
|
| 109 |
+
| 6 | + targeted seeds for the two shapes stock fails | 0.97 |
|
| 110 |
+
|
| 111 |
+
Three findings here deserve their own bullets:
|
| 112 |
+
|
| 113 |
+
- **Label conventions are silent killers.** SMCalFlow annotates "next Tuesday" (said on a
|
| 114 |
+
Monday) as *tomorrow*; our app's convention is *Tuesday of next week*. 74 imported rows
|
| 115 |
+
trained the bug in. Filtering them fixed it β until other data shifts brought it back.
|
| 116 |
+
When you convert someone else's dataset, you inherit someone else's semantics.
|
| 117 |
+
- **Small evals lie.** At n=28 (22 gold events), one event = 4.5 recall points, and we watched
|
| 118 |
+
**4 added training rows flip 3 eval cases**. Run-to-run SFT jitter swamped the signal β runs
|
| 119 |
+
3β5 were a seesaw, not progress. Expanding to 60 examples / 50 events made the gate mean
|
| 120 |
+
something again.
|
| 121 |
+
- **Some priors resist data.** The "next Tuesday = tomorrow" prior survived *seven* explicit
|
| 122 |
+
counter-examples. Stock makes the same error. Genuinely ambiguous English stays ambiguous.
|
| 123 |
+
|
| 124 |
+
Run 6 vs stock E4B (with the same engineered prompt): **identical confusion counts** β
|
| 125 |
+
48/50 events, tp/fp/fn 48/1/2, F1 0.97 = 0.97. A dead statistical tie.
|
| 126 |
+
|
| 127 |
+
## Act V: The bare-prompt tiebreaker
|
| 128 |
+
|
| 129 |
+
The classic argument for SFT at parity is internalization: the fine-tune shouldn't *need* the
|
| 130 |
+
prompt. So we measured it β same 60 examples, system prompt deleted for both models, identical
|
| 131 |
+
minimal user content, same JSON-schema constraint:
|
| 132 |
+
|
| 133 |
+
| bare, n=60 | stock E4B | fine-tuned E4B |
|
| 134 |
+
|---|---|---|
|
| 135 |
+
| schema validity | 0.967 | **1.0** |
|
| 136 |
+
| no-event accuracy | 0.70 | **0.80** |
|
| 137 |
+
| clarification recall | 0.50 | **0.625** |
|
| 138 |
+
| event F1 | **0.682** | 0.644 |
|
| 139 |
+
|
| 140 |
+
The fine-tune is more *disciplined* bare (never breaks schema, hallucinates less, asks more);
|
| 141 |
+
stock edges bare extraction. No decisive gap. **Final verdict: at this data scale (139
|
| 142 |
+
hand-authored + 2,000 converted examples, QLoRA, 1 epoch), the fine-tune reaches parity with
|
| 143 |
+
its base β not superiority.** It shipped as the project's edge model with exactly that claim on
|
| 144 |
+
the model card, by explicit owner decision; the strict-dominance auto-gate, correctly, never
|
| 145 |
+
promoted it.
|
| 146 |
+
|
| 147 |
+
## What we'd tell you to do differently
|
| 148 |
+
|
| 149 |
+
1. **Build the eval before the fine-tune, and gate every publish on it.** Ours rejected eight
|
| 150 |
+
bad models, caught a regression that had already overwritten a good artifact (server-side
|
| 151 |
+
`CommitOperationCopy` restored it for free), and converted every failure into a diagnosis.
|
| 152 |
+
The eval harness was the single highest-value artifact of the project.
|
| 153 |
+
2. **Train format must equal serve format β and verify it in the artifact.** Read the chat
|
| 154 |
+
template out of the exported GGUF's metadata. Assert it in the training script. A template
|
| 155 |
+
mismatch doesn't error; it just quietly ruins everything at a rate proportional to your
|
| 156 |
+
training steps.
|
| 157 |
+
3. **Suspect the training stack on new architectures.** The same recipe destroyed Gemma-4-31B
|
| 158 |
+
and trained Gemma-4-E4B flawlessly, six times in a row. Framework warnings about unhandled
|
| 159 |
+
submodules (`Gemma4AudioModel`) are not noise.
|
| 160 |
+
4. **Exonerate quantization cheaply before blaming it.** Sweep the same weights across
|
| 161 |
+
f16/Q8/Q4 in one GPU lease. Ours cost a few dollars and killed the most plausible-sounding
|
| 162 |
+
hypothesis of the whole project.
|
| 163 |
+
5. **Put deterministic facts in the prompt instead of hoping the model memorized them.**
|
| 164 |
+
Weekday-in-the-prompt improved every model, including the ones we didn't train.
|
| 165 |
+
6. **Match your eval's resolution to your iteration size.** If one flipped case moves a gated
|
| 166 |
+
metric by 4+ points, your gate is a coin flip.
|
| 167 |
+
7. **Diff label conventions when importing datasets.** Resolution semantics ("next DOW"),
|
| 168 |
+
reference-time handling, and reply style all transfer β whether you want them to or not.
|
| 169 |
+
8. **Respect the parity outcome.** Against a strong instruction-tuned base on a narrow,
|
| 170 |
+
well-prompted task, SFT parity is a common honest result. The defensible claims left are
|
| 171 |
+
discipline-under-no-prompt, token savings, and convention control β claim those, not wins
|
| 172 |
+
you didn't measure.
|
| 173 |
+
|
| 174 |
+
### What might still beat the base
|
| 175 |
+
10β100Γ more *real* (non-template) data; full fine-tuning rather than QLoRA once the stack
|
| 176 |
+
supports the architecture; preference optimization (DPO) specifically on the
|
| 177 |
+
clarify-vs-extract boundary; and a harder eval where the ceiling isn't 0.97. The gate is
|
| 178 |
+
already in place to referee all of it.
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
*Artifacts: model repo
|
| 183 |
+
[`ParetoOptimal/gemma-4-cal-gguf`](https://huggingface.co/ParetoOptimal/gemma-4-cal-gguf)
|
| 184 |
+
(31B v1 + E4B edge + mmproj), eval harness `training/eval.py` + `training/data/eval.jsonl`,
|
| 185 |
+
gate `training/gated_retrain.py`, importer `training/import_smcalflow.py` (SMCalFlow,
|
| 186 |
+
CC BY-SA 4.0 β Semantic Machines et al., TACL 2020), full run-by-run log in
|
| 187 |
+
`docs/eval-roadmap.md`.*
|
docs/blog-offgridschedula.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Off the Grid, On Schedule
|
| 2 |
+
|
| 3 |
+
*A solo Build Small project β one person, two small local models, and a calendar problem worth solving.*
|
| 4 |
+
|
| 5 |
+
## TL;DR
|
| 6 |
+
|
| 7 |
+
- **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.
|
| 8 |
+
- It runs a **fine-tuned Gemma 4** through **llama.cpp**, **in the Space**. No cloud AI APIs, no account, no app to install.
|
| 9 |
+
- 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.
|
| 10 |
+
- **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.
|
| 11 |
+
- It's also an **MCP server**: any MCP-aware agent can call `extract_events` / `make_ics` / `check_conflicts` as tools.
|
| 12 |
+
|
| 13 |
+
**π 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)
|
| 14 |
+
|
| 15 |
+
## Contents
|
| 16 |
+
- [The problem](#the-problem)
|
| 17 |
+
- [What it does](#what-it-does)
|
| 18 |
+
- [Architecture](#architecture)
|
| 19 |
+
- [Fine-tuning Gemma 4 for calendars](#fine-tuning-gemma-4-for-calendars)
|
| 20 |
+
- [Off the grid: llama.cpp in a Space](#off-the-grid-llamacpp-in-a-space)
|
| 21 |
+
- [From app to agent: MCP, memory, and shared traces](#from-app-to-agent-mcp-memory-and-shared-traces)
|
| 22 |
+
- [Built small, by the rules: constraints & badges](#built-small-by-the-rules-constraints--badges)
|
| 23 |
+
- [Lessons learned](#lessons-learned)
|
| 24 |
+
- [What's next](#whats-next)
|
| 25 |
+
- [Get started](#get-started)
|
| 26 |
+
- [Acknowledgements & citation](#acknowledgements--citation)
|
| 27 |
+
|
| 28 |
+
## The problem
|
| 29 |
+
|
| 30 |
+
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.
|
| 31 |
+
|
| 32 |
+
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.
|
| 33 |
+
|
| 34 |
+

|
| 35 |
+
*The landing page. Paste a chat or a screenshot; get events back.*
|
| 36 |
+
|
| 37 |
+
## What it does
|
| 38 |
+
|
| 39 |
+
Paste the chat (or attach a screenshot of a flyer/invite). The agent returns:
|
| 40 |
+
|
| 41 |
+
1. **The events** β title, start/end, location, a sensible default reminder.
|
| 42 |
+
2. **A conflict check** against a calendar you upload (`.ics`), with clashes flagged.
|
| 43 |
+
3. **A ready-to-send reply** ("Got it β adding both, thanks!").
|
| 44 |
+
|
| 45 |
+
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.
|
| 46 |
+
|
| 47 |
+
## Architecture
|
| 48 |
+
|
| 49 |
+
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.
|
| 50 |
+
|
| 51 |
+
**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.
|
| 52 |
+
|
| 53 |
+
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:
|
| 54 |
+
|
| 55 |
+
```python
|
| 56 |
+
class Event(BaseModel):
|
| 57 |
+
title: str
|
| 58 |
+
start: str # ISO 8601
|
| 59 |
+
end: str | None = None
|
| 60 |
+
location: str | None = None
|
| 61 |
+
reminder_minutes: int | None = 30
|
| 62 |
+
|
| 63 |
+
class ActionPlan(BaseModel):
|
| 64 |
+
reasoning: str
|
| 65 |
+
events: list[Event] = []
|
| 66 |
+
conflicts: list[Conflict] = []
|
| 67 |
+
proposed_times: list[str] = []
|
| 68 |
+
reply_draft: str = ""
|
| 69 |
+
needs_clarification: bool = False
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
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.
|
| 73 |
+
|
| 74 |
+
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.
|
| 75 |
+
|
| 76 |
+
## Fine-tuning Gemma 4 for calendars
|
| 77 |
+
|
| 78 |
+
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**.
|
| 79 |
+
|
| 80 |
+
**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.
|
| 81 |
+
|
| 82 |
+
**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`):
|
| 83 |
+
|
| 84 |
+
> Retrain β upload to a **staging** filename β score on the held-out eval β **gate**
|
| 85 |
+
> (`schema_validity β₯ 0.95`, `event_f1 β₯ 0.81`, `start-exact recall β₯ 0.773`) β
|
| 86 |
+
> **PASS** promotes staging to production (a free server-side copy); **FAIL** deletes staging, production untouched.
|
| 87 |
+
|
| 88 |
+
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.
|
| 89 |
+
|
| 90 |
+
Here's the fine-tune's scorecard (Q4_K_M, n=28), with the base model as context:
|
| 91 |
+
|
| 92 |
+
| Metric | Fine-tune `gemma-4-cal` | Base Gemma 4 (context) |
|
| 93 |
+
| --- | --- | --- |
|
| 94 |
+
| schema validity | 1.00 | 1.00 |
|
| 95 |
+
| no-event accuracy | 1.00 | 1.00 |
|
| 96 |
+
| **clarification recall** | **1.00** | 0.75 |
|
| 97 |
+
| end-time exact | 1.00 | 1.00 |
|
| 98 |
+
| event precision | 0.85 | 1.00 |
|
| 99 |
+
| start-exact recall | 0.77 | 0.955 |
|
| 100 |
+
| event F1 | 0.81 | 0.977 |
|
| 101 |
+
|
| 102 |
+
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.
|
| 103 |
+
|
| 104 |
+
**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.
|
| 105 |
+
|
| 106 |
+
Two honest notes the eval forced onto the page:
|
| 107 |
+
- **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.
|
| 108 |
+
- **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.
|
| 109 |
+
|
| 110 |
+
## Off the grid: llama.cpp in a Space
|
| 111 |
+
|
| 112 |
+
"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.
|
| 113 |
+
|
| 114 |
+
The whole point: your messages never touch a third-party AI service. The default output is a file you own.
|
| 115 |
+
|
| 116 |
+
## From app to agent: MCP, memory, and shared traces
|
| 117 |
+
|
| 118 |
+
**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**:
|
| 119 |
+
|
| 120 |
+
```python
|
| 121 |
+
def extract_events(thread: str, images: list[str] | None = None) -> dict:
|
| 122 |
+
"""Extract calendar events from a chat thread (and optional screenshots).
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
thread: the conversation text to scan for events.
|
| 126 |
+
images: optional base64 data-URIs of screenshots.
|
| 127 |
+
Returns: an ActionPlan (events, conflicts, proposed_times, reply_draft).
|
| 128 |
+
"""
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
β¦plus `make_ics(events)` and `check_conflicts(events, ics_base64)`. The MCP schema is generated straight from the type hints and docstrings.
|
| 132 |
+
|
| 133 |
+
**β¦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.
|
| 134 |
+
|
| 135 |
+
**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.
|
| 136 |
+
|
| 137 |
+
**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.
|
| 138 |
+
|
| 139 |
+
## Built small, by the rules: constraints & badges
|
| 140 |
+
|
| 141 |
+
Build Small comes with hard rules and a sash of collectable badges. Here's the honest scorecard.
|
| 142 |
+
|
| 143 |
+
**The hard constraints.**
|
| 144 |
+
- **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.
|
| 145 |
+
- **A Gradio app on a Hugging Face Space.** A Gradio 6 Blocks UI mounted on a **Docker SDK** Space running llama.cpp.
|
| 146 |
+
- **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).
|
| 147 |
+
- **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.
|
| 148 |
+
- **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.
|
| 149 |
+
|
| 150 |
+
**The track and the sponsors.**
|
| 151 |
+
- π‘ **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.
|
| 152 |
+
- π’ **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.
|
| 153 |
+
- π± **OpenBMB MiniCPM** β the planner behind **Run the agents**, driving the Space's own MCP tools as a visible multi-step agent.
|
| 154 |
+
|
| 155 |
+
**The six badges β all claimed.**
|
| 156 |
+
- π **Off the Grid** β all inference is local llama.cpp; no cloud AI APIs. The only optional outbound call is *your* Google Calendar push.
|
| 157 |
+
- π― **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.
|
| 158 |
+
- π¨ **Off-Brand** β a bespoke landing page, hero + carousel, grouped nav, and custom results/Activity surfaces, far past the stock Gradio look.
|
| 159 |
+
- π¦ **Llama Champion** β the official `ggml-org/llama.cpp` server image runs the GGUF + vision mmproj.
|
| 160 |
+
- π‘ **Sharing is Caring** β one click publishes a **redacted** run trace to the public [`offgridschedula-traces`](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces) dataset.
|
| 161 |
+
- π **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.
|
| 162 |
+
|
| 163 |
+
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.
|
| 164 |
+
|
| 165 |
+
## Lessons learned
|
| 166 |
+
|
| 167 |
+
A field-notes dump of things that cost time here so they don't cost yours:
|
| 168 |
+
|
| 169 |
+
> **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 `</body>` via middleware, so it always executes:
|
| 170 |
+
> ```python
|
| 171 |
+
> html = html.replace("</body>", f"<script>({CAROUSEL_JS})()</script></body>")
|
| 172 |
+
> ```
|
| 173 |
+
|
| 174 |
+
> **`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.
|
| 175 |
+
|
| 176 |
+
> **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.
|
| 177 |
+
|
| 178 |
+
> **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.
|
| 179 |
+
|
| 180 |
+
> **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.
|
| 181 |
+
|
| 182 |
+
> **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.
|
| 183 |
+
|
| 184 |
+
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.
|
| 185 |
+
|
| 186 |
+
## What's next
|
| 187 |
+
|
| 188 |
+
- Polish the optional Mac collector (reading `~/Library/Messages/chat.db`, including the `attributedBody` blobs modern Messages uses).
|
| 189 |
+
- Grow the on-device memory beyond contacts and preferences.
|
| 190 |
+
- Push the E4B edge model further so the fully-local path matches the 31B headline on the hardest relative-date cases.
|
| 191 |
+
|
| 192 |
+
## Get started
|
| 193 |
+
|
| 194 |
+
- **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.
|
| 195 |
+
- **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).
|
| 196 |
+
- **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.
|
| 197 |
+
- **The traces dataset:** [`ParetoOptimal/offgridschedula-traces`](https://huggingface.co/datasets/ParetoOptimal/offgridschedula-traces).
|
| 198 |
+
- **Use it as a tool:** add the Space as an MCP server and call `extract_events` from your own agent.
|
| 199 |
+
|
| 200 |
+
## Acknowledgements & citation
|
| 201 |
+
|
| 202 |
+
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).
|
| 203 |
+
|
| 204 |
+
```bibtex
|
| 205 |
+
@misc{offgridschedula2026,
|
| 206 |
+
title = {OffGridSchedula: a local-first, fine-tuned scheduling agent},
|
| 207 |
+
author = {ParetoOptimal},
|
| 208 |
+
year = {2026},
|
| 209 |
+
howpublished = {\url{https://huggingface.co/blog/build-small-hackathon/offgridschedula}}
|
| 210 |
+
}
|
| 211 |
+
```
|
docs/demo-script.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OffGridSchedula β 95-second demo video script & storyboard
|
| 2 |
+
|
| 3 |
+
**Goal:** show a real busy parent turn a messy group chat into calendar events β locally, on a small
|
| 4 |
+
model β and name every claimed bonus quest out loud at its evidence moment. "Show, tell the judges."
|
| 5 |
+
|
| 6 |
+
> Recording it? The full operator's runbook β environment prep, demo-calendar
|
| 7 |
+
> setup, the talk track and the reasoning behind each line β
|
| 8 |
+
> is in **[demo-walkthrough.md](./demo-walkthrough.md)**.
|
| 9 |
+
|
| 10 |
+
**Model:** `ParetoOptimal/gemma-4-cal-gguf` β our fine-tune of **Gemma E4B** (~4B effective
|
| 11 |
+
params, far under the 32B cap), run via llama.cpp. (The 31B gguf in the repo is a legacy
|
| 12 |
+
artifact; the platform serves the E4B.)
|
| 13 |
+
|
| 14 |
+
**Bonus quests called out on camera** (field-guide names, verbatim): **Off the Grid** Β·
|
| 15 |
+
**Well-Tuned** Β· **Llama Champion** Β· **Sharing is Caring** Β· **Off-Brand**. The sixth claimed
|
| 16 |
+
quest, **Field Notes**, lives on the end card + README (it's a write-up, not a screen).
|
| 17 |
+
|
| 18 |
+
| # | Time | Shot (visual) | Voiceover / on-screen text |
|
| 19 |
+
|---|------|---------------|----------------------------|
|
| 20 |
+
| 1 | 0:00β0:08 | Phone screen: a noisy class group chat scrolling ("Picture day Thursday 9am", "practice moved to Tuesday", "RSVP by Friday"). | "Every parent knows this chat. The dates are in here β and they're easy to miss." |
|
| 21 |
+
| 2 | 0:08β0:20 | Open the Space in a phone browser. Hero: *Catch every invite before it slips away.* Zoom the π badge. | "OffGridSchedula. No app, no account β a small model runs right in the Space. No cloud APIs anywhere: that's the **Off the Grid** quest β the whole thing runs on the model in front of you." |
|
| 22 |
+
| 3 | 0:20β0:34 | Paste the chat β tap **Find the events**. The "thinking" panel streams; flash the HF model card. | "Paste the chat. The model reading it is **Well-Tuned** β our own fine-tune of Gemma, published on Hugging Face with its eval scorecard β running through the llama.cpp runtime: **Llama Champion**." |
|
| 23 |
+
| 4 | 0:34β0:48 | Results: event cards (Picture Day Β· Thu 9:00, Practice Β· Tue 5:00), a β οΈ conflict badge, free-time chips. | "It pulls out the events, checks them against your calendar, and catches the clash β before anything is saved." |
|
| 24 |
+
| 5 | 0:48β0:58 | Screenshot/flyer upload β an event appears from the image. | "A flyer or screenshot works too β the same local model reads the date, time and place from the picture." |
|
| 25 |
+
| 6 | 0:58β1:10 | Copy the ready-to-send reply; tap **Download .ics**; event drops into the calendar. | "It even drafts the reply. Then the events drop into your calendar β Google, Apple, anything that takes a .ics." |
|
| 26 |
+
| 7 | 1:10β1:22 | Activity tab: live pipeline β click **share trace** β the public Hub dataset page. | "Every agent run is observable β and one click publishes a redacted trace to a public dataset for anyone to learn from. **Sharing is Caring**: the open-trace quest." |
|
| 27 |
+
| 8 | 1:22β1:35 | Slow pan over the custom landing page β **π Submission** tab scorecard (all six quests green) β end card with Space URL. | "And everything you've watched is a custom frontend pushed far past the default Gradio look β **Off-Brand**. Local-first, fine-tuned, llama.cpp, open traces, custom UI β built small, by the rules. Try it free β link below." |
|
| 28 |
+
|
| 29 |
+
**B-roll / cutaways:** the rotating use-case cards (carpool, appointments, parties); the Activity tab's
|
| 30 |
+
"time saved this week" tiles.
|
| 31 |
+
|
| 32 |
+
**Capture tips:** record the phone browser at 1080Γ1920; keep the cursor visible on desktop shots; show
|
| 33 |
+
the network panel offline once to prove no cloud calls; pre-load the traces dataset page in a tab
|
| 34 |
+
for shot 7.
|
docs/demo-walkthrough.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Demo video β recording walkthrough & talk track
|
| 2 |
+
|
| 3 |
+
The end-to-end runbook for recording the public demo video: environment prep,
|
| 4 |
+
the shot-by-shot walkthrough with the exact talk track, and *why each line is
|
| 5 |
+
worded the way it is*. The compact storyboard lives in
|
| 6 |
+
[demo-script.md](./demo-script.md); this document is the operator's guide for
|
| 7 |
+
actually producing it for a general-public audience (the hackathon
|
| 8 |
+
Submission-tab finale is replaced with a public closer).
|
| 9 |
+
|
| 10 |
+
## Decisions baked into this walkthrough
|
| 11 |
+
|
| 12 |
+
- **Backend: the live GPU Space.** Record against the real model β streaming
|
| 13 |
+
output, real extraction, working vision. Never record stub mode
|
| 14 |
+
(`USE_STUB_EXTRACTOR=1` output is visibly fake and vision is disabled).
|
| 15 |
+
- **Calendar: a brand-new demo Google account** (e.g. `offgridschedula.demo@`),
|
| 16 |
+
not a personal calendar. Zero PII can appear on camera β no real events, no
|
| 17 |
+
real email in the OAuth chrome β and the calendar is seeded with exactly the
|
| 18 |
+
event that produces the on-screen conflict.
|
| 19 |
+
- **The on-camera "event lands in the calendar" moment uses .ics import**, not
|
| 20 |
+
a server-side OAuth push. `credentials.json`/`token.json` must never live on
|
| 21 |
+
the public Space; importing the downloaded .ics into the demo account's
|
| 22 |
+
Google Calendar shows the same payoff with zero server credentials. (An
|
| 23 |
+
optional locally-recorded one-click-push splice is described at the end.)
|
| 24 |
+
|
| 25 |
+
## Part A β Prep (before recording day)
|
| 26 |
+
|
| 27 |
+
### A1. Demo Google account
|
| 28 |
+
1. Create the account; set a neutral profile name ("OffGrid Demo").
|
| 29 |
+
2. In its Google Calendar (local timezone), seed **one clashing event**:
|
| 30 |
+
`Dentist β Tuesday 5:00 PM`. It collides with "practice moved to Tuesday
|
| 31 |
+
5pm" in the UI's built-in sample thread, which is what makes the β οΈ conflict
|
| 32 |
+
badge fire on camera. Add 1β2 filler events so the calendar looks lived-in.
|
| 33 |
+
3. Export the calendar (Settings β Import & export β Export) and save the
|
| 34 |
+
unzipped file as `demo-existing-calendar.ics` β this gets uploaded in the UI.
|
| 35 |
+
|
| 36 |
+
### A2. Assets
|
| 37 |
+
- **Flyer image** for the vision shot: a simple school-event flyer (title,
|
| 38 |
+
date, time, place) as PNG/JPEG β *not HEIC* (the vision pipeline can't decode
|
| 39 |
+
it), under 4 MB.
|
| 40 |
+
- **Staged iMessage chat** for the opening phone shot: stage a group chat with
|
| 41 |
+
demo contacts containing the storyboard lines ("Picture day Thursday 9am",
|
| 42 |
+
"practice moved to Tuesday 5", "RSVP by Friday") and screen-record it
|
| 43 |
+
scrolling. Never show a real chat.
|
| 44 |
+
|
| 45 |
+
### A3. Space
|
| 46 |
+
1. Put the Docker Space on a GPU tier (T4 is enough for E4B) and **restart
|
| 47 |
+
it** β restarting also clears `/tmp`, so no previously-ingested feed data is
|
| 48 |
+
visible anywhere in the UI.
|
| 49 |
+
2. Confirm `USE_STUB_EXTRACTOR` is unset and `MMPROJ_FILE` is set (vision).
|
| 50 |
+
3. Dry-run after llama-server health is green: sample thread β real streamed
|
| 51 |
+
output; upload `demo-existing-calendar.ics` β conflict badge; flyer β event
|
| 52 |
+
from image.
|
| 53 |
+
4. Relative dates resolve from the **server clock (UTC)** β check the dry-run
|
| 54 |
+
dates land on the expected weekdays and adjust the seeded Dentist event if
|
| 55 |
+
needed.
|
| 56 |
+
|
| 57 |
+
### A4. Recording
|
| 58 |
+
- 1080p+ screen capture (QuickTime / OBS) in a **clean browser profile** logged
|
| 59 |
+
into the demo Google account only.
|
| 60 |
+
- Record the voiceover separately after screen capture β cleaner than
|
| 61 |
+
narrating live, and the talk track below is written to be read.
|
| 62 |
+
|
| 63 |
+
## Part B β Shot list with talk track
|
| 64 |
+
|
| 65 |
+
| # | Time | Screen | Action | Talk track |
|
| 66 |
+
|---|------|--------|--------|------------|
|
| 67 |
+
| 1 | 0:00β0:08 | iPhone clip | Staged class group chat scrolls past | "Every parent knows this chat. The dates are in here β and they're easy to miss." |
|
| 68 |
+
| 2 | 0:08β0:20 | Browser | Open the Space; hero + badges visible; zoom the π badge | "OffGridSchedula. No app, no account β a small model runs right in the Space. No cloud APIs anywhere: that's the **Off the Grid** quest β local-first, the whole thing runs on the model in front of you." |
|
| 69 |
+
| 3 | 0:20β0:34 | Schedule flow | Upload `demo-existing-calendar.ics` first, then tap **Try a sample**; thinking panel streams; flash the model card link | "Paste the chat. The model reading it is **Well-Tuned** β our own fine-tune of Gemma, published on Hugging Face with its eval scorecard β and it runs through the llama.cpp runtime: **Llama Champion**." |
|
| 70 |
+
| 4 | 0:34β0:48 | Results | Event cards appear; β οΈ conflict vs Dentist; free-slot chips | "It pulls out the events, checks your calendar, and catches the clash β before anything is saved." |
|
| 71 |
+
| 5 | 0:48β0:58 | Vision | Start over β drop in the flyer PNG β event extracted from the image | "A flyer or screenshot works too β the same local model reads the date, time and place right off the picture." |
|
| 72 |
+
| 6 | 0:58β1:10 | Export | Copy the drafted reply; **Download .ics**; switch tabs and import it into the demo Google Calendar β events appear on the grid | "It drafts the reply, and the events drop straight into your calendar β Google, Apple, anything that takes a .ics." |
|
| 73 |
+
| 7 | 1:10β1:22 | Activity | Activity tab's live pipeline; click **share trace**; the public dataset page flashes | "Every agent run is observable β and one click publishes a redacted trace to a public Hub dataset for anyone to learn from. That's **Sharing is Caring**: the open trace quest." |
|
| 74 |
+
| 8 | 1:22β1:35 | Closer | Slow pan over the custom landing page β end card with the Space URL | "And everything you've watched β the hero, the cards, that dashboard β is a custom frontend pushed far past the default Gradio look: **Off-Brand**. Open source, runs locally, your messages never leave the machine. Try it free β link below." |
|
| 75 |
+
|
| 76 |
+
Tip for shot 6: pre-open the demo calendar's import dialog in a second tab so
|
| 77 |
+
the on-camera import is two clicks. Tip for shot 7: have the
|
| 78 |
+
`ParetoOptimal/offgridschedula-traces` dataset page pre-loaded in a tab so the
|
| 79 |
+
"published" beat is one cut, not a page load.
|
| 80 |
+
|
| 81 |
+
## Why the talk track is worded this way
|
| 82 |
+
|
| 83 |
+
- **Shot 1 names the pain before the product.** "Every parent knows this chat"
|
| 84 |
+
earns the next 60 seconds from a general audience that has never heard of
|
| 85 |
+
local LLMs β the hook is the missed pickup, not the model.
|
| 86 |
+
- **The bonus quests are named out loud, verbatim, at their evidence moments.**
|
| 87 |
+
This track serves two audiences at once: the public hook stays in shots 1
|
| 88 |
+
and 4β6, while the field guide's quest names β **Off the Grid** (local-first,
|
| 89 |
+
no cloud APIs), **Well-Tuned** (published fine-tune), **Llama Champion**
|
| 90 |
+
(llama.cpp runtime), **Sharing is Caring** (open trace on the Hub), and
|
| 91 |
+
**Off-Brand** (custom UI past stock Gradio) β are each spoken exactly when
|
| 92 |
+
the screen shows the proof. Judges score the video against those names;
|
| 93 |
+
making them explicit means no claimed quest can be missed on a single watch.
|
| 94 |
+
(The sixth claimed quest, **Field Notes**, is print, not screen β it lives on
|
| 95 |
+
the end card and in the README, not the voiceover.)
|
| 96 |
+
- **Shot 2 still kills the two adoption objections immediately** ("no app, no
|
| 97 |
+
account") before the first quest callout β the badge language is framed as a
|
| 98 |
+
plain-English promise ("the whole thing runs on the model in front of you"),
|
| 99 |
+
so the callout lands for laypeople too.
|
| 100 |
+
- **Shot 4's "before anything is saved"** is the trust beat β the agent
|
| 101 |
+
proposes, the human disposes. This line pre-empts the "AI touched my
|
| 102 |
+
calendar" fear that kills scheduling tools.
|
| 103 |
+
- **Shot 6 generalizes the payoff** ("Google, Apple, anything that takes a
|
| 104 |
+
.ics") so the demo doesn't read as Google-only β and the on-screen action
|
| 105 |
+
matches the off-grid story: a local file the user chooses to import.
|
| 106 |
+
- **Shot 8 closes on the three-word value stack** β open source, local,
|
| 107 |
+
private β and a single call to action, with the Off-Brand callout folded
|
| 108 |
+
into the visual it describes. People remember one URL and one feeling.
|
| 109 |
+
|
| 110 |
+
## Part C β Edit & publish
|
| 111 |
+
|
| 112 |
+
- Assemble: phone clip β screen capture β end card. Speed-ramp model wait time
|
| 113 |
+
but keep ~2 s of genuine streaming so it reads as live.
|
| 114 |
+
- Burn in captions (most social viewers watch muted). Export 1920Γ1080;
|
| 115 |
+
optionally a 1080Γ1920 vertical crop of shots 3β6 for Shorts/Reels.
|
| 116 |
+
- Pair with the draft copy in [social-post.md](./social-post.md); link the
|
| 117 |
+
video from the README (Build Small hard rule #3).
|
| 118 |
+
- **Pause or downgrade the GPU Space after recording** unless the closer is
|
| 119 |
+
meant to drive live traffic.
|
| 120 |
+
|
| 121 |
+
## Optional: one-click Google Calendar push splice
|
| 122 |
+
|
| 123 |
+
To show the actual **Add to Google Calendar** button instead of the .ics
|
| 124 |
+
import: run the app locally, complete the one-time OAuth flow as the *demo*
|
| 125 |
+
account to mint a local `token.json`, enable the toggle, and record just that
|
| 126 |
+
click β the UI is pixel-identical and splices cleanly. Never upload
|
| 127 |
+
`credentials.json`/`token.json` to the public Space.
|
server/orchestrator.py
CHANGED
|
@@ -169,13 +169,17 @@ def _smol_steps(thread: str, ics_b64: Optional[str],
|
|
| 169 |
def run_orchestrator(thread: str, ics_b64: Optional[str] = None,
|
| 170 |
memory_block: Optional[str] = None,
|
| 171 |
max_steps: int = 6,
|
| 172 |
-
images: Optional[list[str]] = None
|
|
|
|
| 173 |
"""Yield orchestration steps for a thread (+ optional screenshot data URIs);
|
| 174 |
always ends with a 'final' step (or an 'error' followed by the scripted
|
| 175 |
-
fallback's steps).
|
|
|
|
|
|
|
|
|
|
| 176 |
with bus.run_scope("agent"):
|
| 177 |
bus.emit("decision", "agent orchestrator run started")
|
| 178 |
-
if _use_llm_planner():
|
| 179 |
try:
|
| 180 |
yield from _smol_steps(thread, ics_b64, memory_block, max_steps, images)
|
| 181 |
bus.emit("decision", "agent orchestrator run finished (MiniCPM planner)")
|
|
|
|
| 169 |
def run_orchestrator(thread: str, ics_b64: Optional[str] = None,
|
| 170 |
memory_block: Optional[str] = None,
|
| 171 |
max_steps: int = 6,
|
| 172 |
+
images: Optional[list[str]] = None,
|
| 173 |
+
use_planner: bool = True) -> Iterator[dict]:
|
| 174 |
"""Yield orchestration steps for a thread (+ optional screenshot data URIs);
|
| 175 |
always ends with a 'final' step (or an 'error' followed by the scripted
|
| 176 |
+
fallback's steps).
|
| 177 |
+
|
| 178 |
+
Pass use_planner=False to skip the MiniCPM planner entirely and run the
|
| 179 |
+
deterministic scripted path directly (e.g. homepage fast path)."""
|
| 180 |
with bus.run_scope("agent"):
|
| 181 |
bus.emit("decision", "agent orchestrator run started")
|
| 182 |
+
if use_planner and _use_llm_planner():
|
| 183 |
try:
|
| 184 |
yield from _smol_steps(thread, ics_b64, memory_block, max_steps, images)
|
| 185 |
bus.emit("decision", "agent orchestrator run finished (MiniCPM planner)")
|
static/app.css
CHANGED
|
@@ -946,18 +946,3 @@ a.footer-cta:hover { transform: translateY(-2px); filter: brightness(1.07); }
|
|
| 946 |
/* Footer: keep "on Hugging Face" (its own <a> in .footer-meta) on one line
|
| 947 |
instead of breaking as "on Hugging" / "Face". */
|
| 948 |
.footer-meta a { white-space: nowrap; }
|
| 949 |
-
|
| 950 |
-
/* Hardware-degraded banner β revealed by app.py's /health probe (inline JS).
|
| 951 |
-
Hidden by default; the script sets display:block when the model is on weak
|
| 952 |
-
hardware (e.g. real model on a CPU-only Space). */
|
| 953 |
-
#status-banner-host { padding: 0; margin: 0; }
|
| 954 |
-
.status-banner {
|
| 955 |
-
display: none;
|
| 956 |
-
padding: 10px 16px;
|
| 957 |
-
text-align: center;
|
| 958 |
-
font-weight: 600;
|
| 959 |
-
font-size: 0.95rem;
|
| 960 |
-
line-height: 1.4;
|
| 961 |
-
background: var(--accent);
|
| 962 |
-
color: #fff;
|
| 963 |
-
}
|
|
|
|
| 946 |
/* Footer: keep "on Hugging Face" (its own <a> in .footer-meta) on one line
|
| 947 |
instead of breaking as "on Hugging" / "Face". */
|
| 948 |
.footer-meta a { white-space: nowrap; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ui/blocks.py
CHANGED
|
@@ -807,7 +807,8 @@ def _on_analyze(conversation: str, cal_path, image_paths, mem_json: str = ""):
|
|
| 807 |
steps: list[dict] = []
|
| 808 |
final = None
|
| 809 |
for step in run_orchestrator(conversation or "", ics_b64=ics_b64,
|
| 810 |
-
memory_block=memory_block, images=images or None
|
|
|
|
| 811 |
steps.append(step)
|
| 812 |
if step.get("kind") == "final":
|
| 813 |
final = step
|
|
@@ -1887,11 +1888,6 @@ def build_demo() -> gr.Blocks:
|
|
| 1887 |
# Theme/css set on the Blocks so the Off-Brand look applies no matter who
|
| 1888 |
# launches it (our uvicorn locally, or HF's gradio runtime on a Spaces deploy).
|
| 1889 |
with gr.Blocks(theme=THEME, css=CSS, title="OffGridSchedula") as demo:
|
| 1890 |
-
# Hardware-degraded banner β hidden by default. app.py injects a tiny
|
| 1891 |
-
# script that calls /health on load and reveals this when the model is on
|
| 1892 |
-
# inadequate hardware (e.g. real model on a CPU-only Space).
|
| 1893 |
-
gr.HTML('<div id="status-banner" class="status-banner" role="alert"></div>',
|
| 1894 |
-
elem_id="status-banner-host")
|
| 1895 |
gr.HTML(_nav_html())
|
| 1896 |
|
| 1897 |
# Per-user memory lives in the visitor's browser (localStorage); this hidden
|
|
|
|
| 807 |
steps: list[dict] = []
|
| 808 |
final = None
|
| 809 |
for step in run_orchestrator(conversation or "", ics_b64=ics_b64,
|
| 810 |
+
memory_block=memory_block, images=images or None,
|
| 811 |
+
use_planner=False):
|
| 812 |
steps.append(step)
|
| 813 |
if step.get("kind") == "final":
|
| 814 |
final = step
|
|
|
|
| 1888 |
# Theme/css set on the Blocks so the Off-Brand look applies no matter who
|
| 1889 |
# launches it (our uvicorn locally, or HF's gradio runtime on a Spaces deploy).
|
| 1890 |
with gr.Blocks(theme=THEME, css=CSS, title="OffGridSchedula") as demo:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1891 |
gr.HTML(_nav_html())
|
| 1892 |
|
| 1893 |
# Per-user memory lives in the visitor's browser (localStorage); this hidden
|