This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
Deal Sloth is a local tool for someone who wholesales houses, or buys them to fix, and who gets a pile of deal-blast texts and emails and does not read them. You export the messages to .txt or .eml, drop them in a folder, and leave the tool running overnight. In the morning the top of a markdown file is two lines.
I ran it on the 15 fictional samples in the repo, with Gemma 3 4B extracting the fields. The top of the report was:
3 of 13 deals worth a call: 410 Willow Lane (cushion $36k), 19 North Ave Atlanta GA (cushion $34k), and 88 Pine Ave (cushion $29k).
6 maybe, 4 skip. 2 messages were not deals. Extractor: gemma3:4b.
Under that is one row per message. The row has the asking price, the after-repair value, the rehab estimate, the rent if the message stated one, the maximum allowable offer, the cushion, a verdict, and which extractor produced the fields.
The verdict is the 70% rule, and it is ordinary Python. MAO is 70% of ARV minus rehab. The cushion is the MAO minus the asking price. $15,000 or more is a call, unless a hard flag pulls it down to maybe. A cushion of $0 or more that is under $15,000 is a maybe. Below $0 is a skip. A missing asking price or a missing ARV is a skip, because there is nothing to compute. Two of the samples are not deals. One is a meetup text. One is a coaching-list email. They are filed as skip and counted on the second line, not in the 13.
The same run moves each file into archive/call, archive/maybe, or archive/skip. The new name is the date, the street, and the verdict. The Pine email became archive/call/2026-10-18_88-pine-ave_call.eml. That date is the Date header on the email, not the day I ran the tool. A sha256 of the raw bytes goes into a state file, so a second run does not score the same message again.
I have not pointed this at a real inbox. The samples were written for the repo. The phone numbers are 555 numbers. Nobody else has used it.
There is no hosted demo. I did not deploy it.
From deal-sloth/:
python3 -m pip install -e .
mkdir -p inbox && cp samples/* inbox/
python3 -m deal_sloth run --inbox inbox --report reports/morning.md
If Ollama is not running, that still writes a report and the extractor column says offline. For the model:
ollama pull gemma3:4b
ollama serve
The run behind examples/morning-report.md used gemma3:4b. The command took 214 seconds, from 01:38:05Z to 01:41:39Z. Stdout was:
deal-sloth: 15 file(s) in the inbox, 15 new, 0 already handled
extractor: gemma3:4b
3 of 13 deals worth a call: 410 Willow Lane (cushion $36k), 19 North Ave Atlanta GA (cushion $34k), and 88 Pine Ave (cushion $29k).
6 maybe, 4 skip. 2 messages were not deals. Extractor: gemma3:4b.
report: /workspace/deal-sloth/examples/morning-report.md
The machine was a KVM guest with 4 vCPUs. /proc/cpuinfo calls the processor Intel(R) Xeon(R) Processor. RAM is 16,387,280 kB. Ollama was 0.40.6. The loaded model was gemma3:4b, Q4_K_M, context 4096. ollama ps showed about 2.9 GB and a 33% CPU / 67% GPU split. I could not see a GPU. nvidia-smi is not installed, /dev/dri does not exist, and the Ollama installer said it did not detect an NVIDIA or AMD GPU. I am not going to name a card I could not find.
The text capture of this run is docs/run.txt.
https://gitlab.com/cryptobandit/deal-sloth
MIT license. The project is the deal-sloth/ directory.
The model and the math are separate on purpose.
deal_sloth/analyze.py computes the MAO and the cushion and picks call, maybe, or skip. The thresholds are constants at the top of that file. tests/test_analyze.py locks them, including 70% of $310,000 minus $48,000 of rehab, which is a MAO of $169,000, and including $100,001. 70% of $100,001 is $70,000.70. maximum_allowable_offer keeps $70,000. It takes the dollars as ints and uses floor division, (arv * 70) // 100 - rehab, so the 70 cents are dropped. It does not round to the nearest dollar, and it does not use Decimal. The model does not see a deal until those functions have already run. It cannot change a verdict.
deal_sloth/extract.py asks an OpenAI-compatible chat endpoint for one JSON object: is it a deal, address, asking price, ARV, rehab, rent, contact. The default base URL is http://127.0.0.1:11434/v1, which is Ollama. The default model is gemma3:4b. Three settings override that, either as flags or as DEAL_SLOTH_BASE_URL, DEAL_SLOTH_MODEL, and DEAL_SLOTH_API_KEY. An empty key is what a local Ollama expects. llama.cpp's server or vLLM is the same request.
If the server does not answer, a regex extractor runs the whole batch. If the server answers and one message comes back as something that is not usable JSON, or as JSON whose numbers are not written in that message, that message alone falls back. The report names the extractor on the row.
On this run none of the 15 fell back. Afterward I compared asking price, ARV, rehab, rent, verdict, and flags with the regex results. They matched on every file.
For a call there is a second request. The model is told to write one short sentence, not to change a number, and not to add one. If the sentence contains a figure that is not in the analysis or the street address, it is dropped. That check is narrower than it sounds. The Pine Ave sentence in the report says "210000 ARV". 210000 is the asking price. The ARV is 420000. Rehab 55000, rent 2100, and the cushion 29000 in that same sentence do match the table. 210000 is an allowed number, so the sentence stayed. The verdict was already call before the sentence was written. The Willow sentence (MAO 156000, cushion 36000) and the North Ave sentence (cushion 34000) match the table.
pytest covers the math, every sample through the regex extractor, JSON validation, a mocked HTTP call to /v1/chat/completions, per-message fallback, the sentence check, archive names, and a second run that does not score the same bytes again. 40 tests passed. None of them call a live model.
Overnight is either a cron line at 06:00 or python3 -m deal_sloth watch --every 300, which rewrites the same report as new files show up. --every is seconds.
A deal blast is the lead. The address, the price, the rehab number, and a phone number are what a wholesaler should not paste into a hosted chat box. Pointing the extractor at a local open-weight model keeps that text on the machine. A closed API would make the privacy of the lead depend on a vendor's retention policy, and it would bill for the extraction. The extraction is the small part.
The part that has to be the same every morning is the subtraction. MAO and cushion live in analyze.py so a test fails if they drift. Gemma 3, which Ollama reported as 3.9B parameters on this run, is enough to copy messy text into fields. That is why I did not fine-tune anything, and why a smaller Gemma tag is a config change (DEAL_SLOTH_MODEL=gemma3:1b) rather than a different tool. If a better small open model comes out, the analyzer stays. You change the model name.
The seam that makes the swap possible is an open chat API in front of the weights. Ollama serves Gemma on the OpenAI-compatible endpoint. The same client talks to anything else that implements that endpoint. I would not have that choice if the only way to read the message was a closed vendor SDK.
Best Use of Gemma