ParetoOptimal commited on
Commit
bc8328b
Β·
verified Β·
1 Parent(s): 3d6be29

Upload folder using huggingface_hub

Browse files
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, health, threads
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 health_route():
138
- # Liveness + hardware-adequacy (device/model/degraded/reason). The on-page
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 + _BANNER_INLINE + "</body>", 1)
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
+ ![The dark hero of the OffGridSchedula landing page: "Schedule your invite before it slips away."](assets/offgridschedula/hero.png)
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) -> Iterator[dict]:
 
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