Hackathon prompt: build something with open-source AI at its core for the Hacktoberfest Open-Source AI Challenge. Theme: 'Slow and Steady' - build something that rewards laziness, that does the work so you don't have to. Open innovation matters: offline, private, swappable, free. Writing quality is weighted most heavily in judging.
First I want the real rules, not the summary. The DevRelay gateway can read the live event, including the unabridged full_details dump the organizers publish for agents.
{"id"=>81}
Hacktoberfest Open-Source AI Challenge: Week 3. Oct 19-25. Theme 'Slow and Steady'. $2,850 across 21 winners. Required tag #hf26challenge. Submissions due Oct 25 11:59 PM PDT. Judging: Writing Quality (most), Relevance, Creativity, Technical Execution, Partner Technology.
Scope confirmed. Now the machine: I need to know whether local inference is even viable here, and how much disk it costs, before I commit to a design.
$ {"command"=>"check python, node, ollama, gpu, ram"}
Python 3.12/3.11, Node 22, no ollama on PATH. AMD Radeon integrated (2GB), no NVIDIA. 12 logical CPUs, 14.9GB RAM. 59.8GB free on C:.
total how much gb ita consume for three models
$ {"command"=>"measure exact model + runtime sizes from the Ollama registry"}
qwen2.5vl:3b = 3052 MB, qwen2.5:3b-instruct = 1841 MB, nomic-embed-text = 262 MB, ollama-windows-amd64.zip = 1403 MB. Total ~6.55 GB. 59.8GB free on disk, so disk is a non-issue; RAM is the tighter constraint.
CPU-only inference, 6.55GB, approved. Now the design.
The theme is laziness, and the interesting technical question is not 'can a small model do vision' - it is 'how do you build an agent whose default posture is to do nothing'. So the spine of the project is a cascade that stops at the cheapest stage it can be confident at:
stage 0 hash check free, and it means the agent sleeps when nothing changed
stage 1 metadata gate free, 0 tokens, filename and folder decide most files
stage 2 cheap vision gate a tiny VLM, optional
stage 3 escalate the reasoning model, only when genuinely unsure
stage 4 one digest one page for the whole batch, never one per file
Plus semantic duplicate suppression: embed the draft digest, compare with the last one, stay silent if the cosine is above 0.965. Re-photographing the same fridge must not produce a second shopping list.
Writing the core now. Zero third-party runtime dependencies - it talks to Ollama over plain HTTP with urllib, so it runs on a bare Python 3.10+ install.
{"files"=>["siesta/agent.py", "siesta/router.py", "siesta/store.py", "siesta/prompts.py"]}
The orchestrator, the three-stage router, the SQLite store (items, digests, expenses, and a cost ledger of every model call), and the prompt/JSON-schema layer. Every model call passes a JSON Schema to Ollama's `format` field, so the runtime constrains generation instead of the pipeline regexing prose.
$ {"command"=>"pytest -q"}
12 failed, 40 passed. Two real bugs: (1) the energy-aware default made the agent refuse to run at all on a laptop on battery; (2) the tidy chore excluded its own trigger file from the listing, so it never found anything to sort.
Both fixed. The energy gate becomes opt-in rather than the default, because an assistant that never runs is not helpful.
$ {"command"=>"pytest -q"}
52 passed.
Now the part that matters - testing against the real models rather than the stub. First call to the live vision model failed:
$ {"command"=>"call qwen2.5vl:3b with a photo"}
HTTP 400: json: cannot unmarshal array into Go struct field .ChatRequest.messages.content of type string
A real bug the stub could never have caught: Ollama's native /api/chat wants content as a plain string. The OpenAI-compatible endpoint accepts an array of content parts, but the native one does not. My message builder was OpenAI-shaped. Fixed - images travel in the request's images array, not inside the message content.
$ {"command"=>"call qwen2.5vl:3b with a photo again"}
48.6s wall clock, 514 eval tokens, valid schema-conformant JSON. The fridge list looked convincing: milk, cheddar cheese, chicken thighs, eggs. But it also listed flour, jam, olives and tinned tomatoes, which are not in the photo, and it missed the spinach, the yoghurt and the bananas.
Structurally valid and completely wrong. A hallucinating fridge inventory is worse than no inventory at all, so before building anything on top of this I ran a prompt experiment with four strategies and measured precision and recall against ground truth I annotated by hand.
$ {"command"=>"prompt experiment A/B/C/D on the fridge photo"}
A (one-shot full record): precision 100%, recall 33%. B (shelf-anchored enumeration + per-item confidence): precision 100%, recall 67%. C (count-then-list): precision 0%, recall 0%. D (strict count, generic items): precision 17%, recall 11%.
Strategy B wins and the reason is interesting. Asking a 3B vision model for full structured records in one shot invites it to fill in a plausible fridge. Asking it to enumerate what it can see, shelf by shelf, with a confidence for each object, killed the hallucinations outright.
So the pantry chore becomes three steps: scan with the vision model (short output, so it is also fast on a CPU), filter on confidence, then let the cheap text model fill in category/quantity/expiry for the confirmed names only. The text model cannot introduce an item that was never confirmed.
{"files"=>["siesta/chores/pantry.py", "siesta/prompts.py"]}
Pantry chore restructured into scan -> filter -> enrich, with a CONFIDENT_AT threshold. Items below the threshold are set aside as *uncertain* rather than silently trusted or silently dropped.
$ {"command"=>"full end-to-end run with the real models"}
Pantry: confirmed milk, eggs, cheddar cheese, spinach, chicken thighs, yogurt, bananas, olive oil - 8 of 8, zero invented. 3/3 files routed by the metadata gate, so zero routing model calls.
Eight of eight, zero invented, and I was about to write that up as a success. Then the receipt came back wrong - 'The Store, 100.00 USD, 3 items' for a receipt whose printed total is $32.24. Same root cause, same fix: transcribe with the vision model, parse with the text model.
{"files"=>["siesta/chores/receipt.py"]}
Receipt chore restructured to transcribe -> parse: the vision model reads the text off the paper verbatim, the text model structures it and does the arithmetic. Added a consistency check - if the printed total disagrees with the sum of the lines, the chore says so instead of hiding it.
$ {"command"=>"try again"}
Still wrong, but differently wrong: 'Walmart', 'Item 1: Apple - $0.99', 'TOTAL $19.99'. None of that is on the receipt.
And then the giveaway. Four different preprocessed images came back with byte-identical answers. Four different images cannot produce the same output, so the image was not being processed at all - the model was answering from the prompt alone.
$ {"command"=>"test image placement against the live server"}
request-level images: prompt_tokens=32, answered 'Red, blue, and green'. message-level images: prompt_tokens=1100, answered 'Red and green'. Ollama accepts a request-level 'images' field and silently ignores it.
That was the whole bug, and it had been there from the start.
Ollama's /api/chat accepts a request-level images field and silently ignores it. The model then answers from the text of the prompt alone. And because my prompt listed plausible example items - 'milk, eggs, cheddar cheese, spinach' - the blind model parroted my own prompt back at me, which looked exactly like a perfect reading of a fridge I had photographed.
The eight-of-eight score was an illusion. The model had never seen the picture; it had copied my instructions, and my instructions happened to mention the right things because I had written them after looking at the photo myself.
Two things came out of that. First, the client now puts the images inside the message, where Ollama actually reads them, and the real prompt token count jumped from 198 to 1587 - the picture is now genuinely in the context. Second, and more important, the project now verifies that the image arrived: an attached image costs about a thousand prompt tokens, so every vision call asserts on that, and siesta doctor sends a red-and-green test picture and checks the model can describe it.
{"files"=>["siesta/visioncheck.py", "siesta/backends/ollama.py", "siesta/chores/base.py"]}
Image_tokens on every response, a saw_the_image() predicate, a red/green self-check in `siesta doctor`, and a hard failure in the vision chores when the picture did not arrive - so a blind model can never quietly fill the pantry with things that are not in it.
{"files"=>["scripts/accuracy_experiment.py"]}
The evaluation harness, now careful about two things: the prompt contains no ground-truth names, so the model cannot copy them, and the script refuses to report a score if the prompt token count says the image never arrived.
$ {"command"=>"re-run the accuracy experiment, honestly"}
prompt_tokens 1587 (the image is really there now). 8 objects found: milk carton, egg carton, cheese, spinach, chicken, yogurt, bananas, olive oil. Precision 88% (one duplicate milk carton), recall 100% against hand-annotated ground truth.
That is the real number, and it is a real number rather than a flattering one. A 3B open-weight vision model on a CPU-only laptop reads a fridge photo with high recall and occasional duplicates, and it does not read a crumpled receipt reliably - which is why the receipt chore now verifies the printed total against the sum of the lines and says so when they disagree, instead of writing a fake expense into the ledger.
And this is the part that makes the open-weights argument concrete rather than sentimental: the fix for a weak model is a file. If a 7B vision model reads receipts better, that is one flag. With a hosted API the only remedy for 'the small model cannot do this' is to wait for the vendor to ship a better one.
The receipt took one more round. Asking the vision model for an array of strings - one entry per line of the receipt - made it degenerate: it echoed my instructions and then produced 100.00, 200.00, 300.00... until it ran out of tokens. The same trick that fixed the fridge fixed this: ask for one plain string, and let the text model do the structuring.
$ {"command"=>"transcribe the receipt into a single string"}
Cafe Bistro Heat / 1881 Ave. N. / ORGANIC MILK 3.99 / AVOCADOS 2.50 / EGGS 4.29 / BREAD 4.49 / APPLES 3.49 / CHICKEN 5.12 / 12.85 / TOTAL: $32.24
$32.24, and every item name and price correct. The printed total and the line items agree, so the expense lands in the ledger as a verified fact rather than a guess.
Two smaller bugs fell out of the same run. The shopping list was telling me to buy milk while the fridge photo showed a milk carton, because the staple list said 'milk' and the model said 'milk carton' and the comparison was a raw string match. Now there is a canonical-name function that drops packaging words and plurals. And the plan step was inventing recipes - 'preheat oven to 350F, slice bread and spread with butter' - for a fridge that contained no bread, so its prompt now says every step must refer to a named item and nothing else.
$ {"command"=>"final end-to-end run + cascade benchmark"}
Receipt read correctly at $32.24. The nap behaved exactly as designed: the second pass made zero model calls, touching a file with identical bytes also made zero model calls, and changing one file woke the agent. Benchmark over two nights, three files: naive 12 calls / 8,611 tokens / 6 pages for the user; cascade 5 calls / 5,182 tokens / 1 page. 39.8% fewer tokens, and night two costs literally nothing. 3/3 files routed without a model.
One more fix along the way, worth recording: the plan prompt was asking for 'the smallest set of actions', and the model helpfully invented a recipe - 'preheat oven to 350F, slice bread and spread with butter' - for a fridge that contained no bread. The prompt now says every step must refer to a named item, and nothing else.
$ {"command"=>"serve the web UI and hit every endpoint"}
500 on /api/status. Cause: FastAPI runs sync handlers in a threadpool, and the SQLite connection was bound to the thread that opened it, so the first request from a worker thread raised 'SQLite objects created in a thread can only be used in that same thread'. Fixed with check_same_thread=False plus a lock around the writes, and a regression test that hammers the store from four threads at once.
Shipped. The repo is at github.com/faijaleaqbal/siesta, with 61 tests that run against a deterministic stub backend so a judge can clone it and run pytest with no model download at all.
The closing thought, which is really the whole submission: the theme was 'Slow and Steady', and the honest reading of it turned out to be about restraint rather than speed. Hosted AI pushes hard the other way - every call costs money, so the systems get louder and chattier. Running the weights locally is what made the quiet version possible.
And the second thing, which I did not expect: in a system that reads the world, you have to prove that it looked. My best-looking result was the model copying my own prompt back at me. The check that caught it - count the tokens, and test the model against an image whose answer you already know - is three lines long, and it is now the part of this project I am proudest of.