DEV Community

Dariusz Dumnicki
Dariusz Dumnicki

Posted on AI-assisted

Weekend Micro-Adventure

Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass Submission 🌿

This is a submission for the Hacktoberfest Open-Source AI Challenge: Week 1: Touch Grass

What I Built

Weekend Micro-Adventure is a one-screen web app that gets you off the screen.

You pick three things β€” how long you have, how you'll get there, and what mood you're in β€” type a city, and press Send me out. You get exactly one card: one real place nearby, a short plan, and a leave-by time. There is one button underneath, Not feeling it, which gives you a different place exactly once. Then it's gone.

There is no feed, no list of options, no login, no settings. The design constraint I set myself was that the screen must be the shortest part of the experience, so I kept a timer in the footer that shows how long you've been staring at it. The intended caption for the demo is "Screen time: 10s. Outside time: 45+ min."

It's live: weekend-micro-adventure.onrender.com

The reason a language model is genuinely useful here isn't "it writes text." It's three specific jobs:

  1. Pick the best place from a shortlist for your mood and constraints.
  2. Turn raw search results into something readable in under ten seconds.
  3. Be honest. If a detail isn't in the data, say "check before you go" instead of making it up.

That third one was the hard part, and most of this write-up is about how I made it hold.

Demo

Live app deployed on Render:
weekend-micro-adventure.onrender.com

One screen, three choices, one card.

Type the city, address or share your location, choose how much time you can spend on your adventure, how you want to get there and the vibe you want.
Select options

Click on "Send me out" and Gemma will prepare a plan for your adventure, taking into account the current weather, time and the which places are open. You can always re-roll for a different plan using "not feeling it" button.
Adventure plan

Code

GitHub logo dumnicki / weekend-micro-adventure

Weekend Micro-Adventure - Discover new places around you and plan your adventure

Weekend Micro-Adventure

One screen. Pick time, transport, vibe, location. Press Send me out. Get one real adventure card β€” one place, a short plan, and a leave-by time. No lists, no feed, no login.

Gemma (open-weights) runs through Backboard. Places come from SerpApi Weather and daylight from Open-Meteo (free, no key).

Run locally

pip install -r requirements.txt
cp .env.example .env      # then fill in the keys
uvicorn app.main:app --reload
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:8000 . Health check: GET /api/health -> {"status":"ok"}.

Run tests

pytest -q
Enter fullscreen mode Exit fullscreen mode

Tests never call a live API β€” they use saved fixtures and mocks.

Current model

BACKBOARD_MODEL=google/gemma-4-26b-a4b-it
BACKBOARD_PROVIDER=openrouter

scripts/list_models.py lists every Backboard model matching gemma (there are ~1000, mostly community fine-tunes on the featherless provider). Pick the openrouter entry for the Google-namespaced model: 262k context, versus 32k on the featherless duplicate.

Typical model latency

A one-shot generate_json call against Gemma returns in about 1.5–2s. That…

How I Built It

Stack: Gemma 4 26B-A4B (open weights) served through Backboard, SerpApi for real places with coordinates and ratings, Open-Meteo for weather and sunrise/sunset (free, no API key), FastAPI, and one index.html with plain JavaScript. No front-end framework, no build step, no database.

The one architectural decision that mattered

Code decides, the model writes.

Every decision that can be made from a number is made in Python: distance filtering, the time math, the daylight cutoff, scoring, deduplication, even the radius per transport. The model does the only two things code is bad at β€” picking one item from eight, and writing it in a human voice.

This is not a stylistic preference. It meant that when the model failed β€” and it did, repeatedly β€” the failure degraded to a plain template card instead of an error page. The app has never once returned a 500. Model timeout, malformed JSON, Backboard down: all of it falls back to a code-built card.

It also made the model swappable. BACKBOARD_MODEL is an environment variable. Gemma does the least critical job in the pipeline, so swapping it out doesn't break the safety properties β€” it just changes the prose.

Controlling hallucination

The risk is a made-up trail or a made-up opening time, and a confident lie about where to walk is worse than no answer. Five layers:

  1. The model only ever sees 8 pre-filtered candidates and must pick by ID.
  2. The system prompt forbids anything not present in the candidate data.
  3. The server validates the output β€” this is the part I got wrong twice.
  4. Every card carries a check_before_you_go field.
  5. The place name, address, coordinates and map link come from code, never from the model. A hallucinated title cannot move you to the wrong park, because the model isn't allowed to supply the coordinates.

This is the same territory people are actively writing about β€” Stop prompting for valid JSON: an LLM output layer that does not break and LLM Structured Outputs and JSON Schema: Tool Calling That Never Drifts β€” and my experience matched theirs: schema validation is table stakes, but schema validation does not catch a wrong number inside a valid shape.

The bug that taught me the most

My first prompt run scored 7 out of 10. Both failures were the same mistake, and it was a genuinely interesting one:

The model kept setting duration_minutes to the total time budget, ignoring the shorter activity_minutes_available on the specific place it had chosen.

It read the global budget (60 minutes) instead of the per-candidate allowance (30 minutes). A perfectly well-formed JSON object with a confidently wrong number in it β€” which no schema catches.

The obvious fix doesn't work, because the rule was correct in the first draft. I had already written "duration_minutes must not exceed activity_minutes_available", and the model ignored it. Burying a hard constraint in a list of eight soft rules doesn't make it a hard constraint.

What worked was making it structural instead of advisory:

  • A max_duration_minutes copy-target field sitting next to each candidate.
  • A worked example in the system prompt showing the wrong answer alongside the right one, including the exact wrong numbers.
  • Restating the rule in the user template immediately below the candidate JSON, ending with the instruction: "Do not use {budget_minutes}."

Next run: 10 out of 10. The lesson generalizes past this app β€” if a small open-weight model won't follow a constraint stated in prose, stop stating it in prose and change the shape of the data it's reading.

The bug my tests could never have caught

Later, a live smoke test returned a card where check_before_you_go was the literal string "None".

That passed every single validation rule I'd written. It's a non-empty string in a string field. My entire suite β€” 244 tests β€” was green while this bug existed, because every test used a mocked model that returns well-formed text.

Tests verify structure. Only a real call shows you what the model actually emits. I now treat bare null markers (None, N/A, null) as empty fields, which triggers the retry path. And I added used_fallback to the API response specifically so I can assert that Gemma wrote the card rather than the template β€” the smoke test now fails the deploy if used_fallback is ever true.

That field exists for one reason: the whole claim of this project is that a model did the work, and I wanted that claim to be checkable rather than assumed.

Smaller things worth knowing

  • A per-request cache was spending money on every re-roll. The re-roll button existed specifically to avoid re-querying SerpApi, but the cache was constructed inside the request handler, so it was always empty. One line moved it to process scope.
  • Daylight is planned against the destination's clock, not the server's, using the forecast's UTC offset. Getting this wrong silently schedules the trip against the wrong sunset.
  • SerpApi's docs were wrong twice. They documented a type_ids field that doesn't exist in the real response, and omitted description from their sample when it actually appears on ~20% of results. Both only showed up by checking a saved real response.
  • 244 tests, none of which touch the network.

Why Does Open Innovation Matter?

The open-weight model is doing the one job where a closed API would have been most tempting, and it turned out to be the least critical job in the pipeline.

Gemma 4 26B-A4B is a mixture-of-experts model β€” 26B total parameters but only ~4B active β€” which is why a full request lands in about two seconds and costs almost nothing. That's not a benchmark win; it's what made a 250-search/month free tier and a free hosting tier enough to actually ship and demo this.

More importantly, the open part is the part I was able to inspect and constrain. I could read the model's behavior across ten identical runs, find the precise number it kept getting wrong, and fix it by reshaping the input. With a closed API, the same bug would have been "tune the prompt and hope." Because the weights are swappable and the model is one module behind an interface (app/services/llm.py is the only file that knows Backboard exists), I can run Gemma today and something else tomorrow without touching the validation, the filtering, or the front end.

And the architecture means openness is load-bearing rather than decorative. Because code computes the distances, the times, the daylight cutoff and the map link, the app's correctness does not depend on the model's good behavior. If the model is down, un-swappable, or replaced with something worse, you still get a correct adventure β€” just in plainer language.

That's the argument I'd make for open models in general: the value isn't only that the weights are free. It's that being able to look closely at a component, and swap it without permission, is what makes the rest of the system trustworthy.

My Agent Session

The full build transcript, including the failures and two misdiagnoses I made along the way:

Weekend Micro-Adventure: three bugs a mocked test suite could not catch
You

Check this project. Look at PLAN.md. We are currently on step 1. You can use google/gemma-4-26b-a4b-it.

Agent

Nearly every file in the repo was an empty 1-line stub, so step 1 was accurate. Only config.py, services/llm.py and scripts/list_models.py had real content.

Step 1's job was to find the exact model id rather than trust the plan's guess, so I ran list_models.py against the live API. It returned 1034 Gemma matches, almost all community fine-tunes on the featherless provider. The model the user named existed twice:

provider model context
openrouter google/gemma-4-26b-a4b-it 262,144
featherless google/gemma-4-26B-A4B-it 32,768

I set BACKBOARD_PROVIDER=openrouter because the featherless duplicate of the same model caps at 32k, and the planner will eventually push full candidate JSON through it.

Step 1 check passed: a real call to generate_json returned a parsed dict in 1.65s.

You

Run the model call just 10 times. Let me know of the results.

Agent

7 out of 10 passed, and honestly only 9 model calls happened: one configured run short-circuited before reaching the model because no place survived filtering.

Both failures were the same bug, and it was a genuinely interesting one. On tight budgets the model set duration_minutes to the total budget_minutes, ignoring the shorter activity_minutes_available on the candidate it had actually chosen:

run constraints duration_minutes activity available
2 1h / bike 60 40
3 1h / transit 60 30

A perfectly well-formed JSON object with a confidently wrong number inside it. All six half_day and full_day runs passed, because on longer budgets the cap never binds and the model never has to reason about it.

You

Proceed with the proposed fix and test with 10 calls.

Agent

Three coordinated changes, because the bug was the model conflating two numbers.

  1. Promoted rule 5 from one advisory line buried among eight rules into a standalone HARD RULE block that names the confusion directly: budget_minutes is the whole trip including travel, activity_minutes_available is time at the place only, and the former is always bigger. Included a worked example listing the exact wrong numbers.
  2. Restated the rule in the user template immediately below the candidate JSON, ending with the instruction to not use budget_minutes.
  3. Added a max_duration_minutes string field next to each candidate, giving the model a literal copy target. Small models copy more reliably than they re-derive.

I also fixed the fixture. The nearest fake place was 1.82 km away, which at 4.5 km/h meant a 63-minute round trip inside a 60-minute budget, so the 1h/foot run correctly returned nothing and never tested the model. Added two places within walking distance.

Result: 10 out of 10, with all three 1h runs passing, which are precisely the cases that failed before.

You

Let's proceed with step 7

Agent

Six planner tests failed on the first run. The planner was correct every time; my test data was wrong.

At 16:20 local, daylight capped the budget to 83 minutes instead of the requested 240. That shrank Planquadratpark's activity allowance to 33 minutes, but my helper emitted duration_minutes of 60. validate_card rejected it, the retry returned the same card, and the fallback fired. Exactly as designed. The Step 2 validator was correctly catching the Step 2 bug inside a live pipeline.

I also found a wrong assumption in my tomorrow-branch test: I had used 23:00 UTC believing it was evening in Vienna, but that is 01:00 local, which is before sunset, so today was the correct answer. My premise was wrong, not the code.

You

Yes, proceed with the step 8.

Agent

I got this diagnosis wrong twice before finding the real cause, and the wrong attempts are the useful part.

Bug one was real and serious. The planner built a fresh TTLCache inside plan_adventure, so the cache was empty on every request. The re-roll button existed specifically to avoid re-querying SerpApi, and it was spending quota on every press. Fixed by moving it to process scope.

Bug two was mine, in the tests. I misdiagnosed the dependency override twice: first blaming the Callable type alias on the route signature, then parameter defaults. I edited routes.py both times before reading the actual 422 body. The mystery missing query params were literally named s and u, because I had written a two-argument function with those names in my own diagnostic script.

The real cause: Depends expects a factory that RETURNS the model function. I had supplied the function itself, so FastAPI called it during dependency resolution with empty strings. Consequences: on the happy path the planner received a dict instead of a function, the call failed, and the card silently fell back; on the broken-model test the RuntimeError escaped before the planner ran at all.

I reverted both wrong route changes rather than leave them in with invented rationale.

You

Now write the article. The demo will be a link to the live app.

Agent

Filled the Hacktoberfest Touch Grass submission template. Updated the existing draft in place rather than creating a second post, and left it unpublished so the author could review it. Flagged the deadline as 11:59 PM PDT that night.

Pointed out what I deliberately did NOT claim: the challenge gives bonus points for actually taking it outside and reporting on it, and that step was still pending, so the post does not assert it happened.

Prize Categories

  • Best Use of Gemma β€” google/gemma-4-26b-a4b-it does the selection and prose for every card.
  • Best Use of Backboard β€” model access, provider routing, and the one call per request budget.
  • Best Use of SerpApi β€” google_maps engine for real places, coordinates and ratings; the entire candidate pool.
  • Best Use of Render β€” the free web service this runs on, defined in render.yaml.

Top comments (0)