{
"query": "select:WebFetch",
"max_results": 1
}
{"url":"https://dev.to/devteam/congrats-to-the-hacktoberfest-open-source-ai-challenge-touch-grass-winners-152c","prompt":"List winners for the Tiger Data track (names, project titles), prize details, ...
**Tiger Data track winner:** Anish Kumar (@anishisbusy) for *Nice Out: an app that learns your kind of nice weather, then tells you to go outside*.
**Prizes:** Partner category winners each receive $100 USD and an exclusive DEV badge. All participants with a valid submission receive a completion badge.
**Claiming prizes:** The page doesn't say how winners claim prizes or what steps they need to take.
**Week 2:** Hacktoberfest Open-Source AI Challenge: Side Hustle is running now, with $2,550 in prizes across 18 winners. The page links to the challenge for entry but doesn't give further details. Week 3 starts October 19, 2026, and Week 4 starts October 26, 2026. You can register on the page to be notified when details are released.
Congrats. Page confirms: Anish Kumar (@anishisbusy) won Tiger Data track, week 1 (Touch Grass), for Nice Out.
Want me to check the week 2 deadline and Tiger Data track rules for ReplyDesk?
well i was thinking that my submission for week2 is not good enough, we need to work more on it
/home/anish-kumar/.claude-work/projects/-home-anish-kumar-Code-hacktoberfest/memory/hf26-week2-replydesk.md
1 ---
2 name: hf26-week2-replydesk
3 description: "Week 2 (Side Hustle) DEV challenge project ReplyDesk — tracks, autonomy grants, design checkpoint, where plan lives"
4 metadata:
5 node_type: memory
6 type: project
7 originSessionId: 70a639e9-078c-4b0f-acd2-d4e7a80e01d3
8 modified: 2026-10-16T11:16:45.161Z
9 ---
10
11 Week 2 of the Hacktoberfest 2026 DEV challenge (theme "Side Hustle", deadline Mon Oct 19 2026, 12:29 PM IST) is **ReplyDesk**: a Qwen3.5-4B fine-tuned on Tinker with Google Local Reviews owner replies, writing Google review replies for small shops. Plan, contest notes and dataset research live in `week2/PLAN.md`, `CONTEST.md`, `DATASETS.md`.
12
13 Tracks: Tinker (main, $200), Tiger Data, Backboard. Dropped .tech, DigitalOcean (need card), Arduino (user only has UNO R3 clone, track needs UNO Q). Week 1 entry was Nice Out ([[kestra-hacktober-track02]] is a separate thing).
14
15 **Why:** user granted these on Oct 13 so the build runs without stopping.
16 **How to apply:** without asking — create public repo Sarcastic-Soul/replydesk and push, deploy to Vercel, spend Tinker credit up to ~$6 of $10, create Tiger service `replydesk` + public HF Space. Design picked: option 1 "Counter Ledger" (done). Package manager: bun. Post: draft POST.md only; user publishes. Constraints: no card on any service, no local LLM serving (7.5 GiB RAM laptop). Secrets in `week2/.env`.
17
18 Service facts found while building (Oct 13), not obvious from docs:
19 - Tinker OpenAI endpoint turns thinking off only with `reasoning_effort: false` (boolean); the string "none" is rejected.
20 - Backboard free credit covers Memory & RAG only; LLM chat calls fail. So Backboard = shop-facts memory. Groq free tier was too slow for the eval, so the 27B baseline and gpt-oss-120b judge run on Tinker; Groq only does the app's promise check.
21 - Hugging Face: Inference credits used up, and Docker/Gradio Spaces now need PRO. So no HF Space; query embeddings run in the browser with transformers.js.
22 - Tiger free service `replydesk` (p247y4knjw) has pg_textsearch (BM25) and vectorscale (DiskANN).
23
24 State on Oct 13 evening: live at https://replydesk-shop.vercel.app (replydesk.vercel.app belongs to someone else). App uses v3 checkpoint `tinker://1cf358f7-...:train:0/sampler_weights/final`. About $4 of Tinker spent. Eval finding: tuned model writes like real owners (34 words, 2% stock phrases, fewer promises), but the LLM judge prefers the prompted 4B/27B (tuned is first pick 5%). So the post frames the eval honestly and leans on the human blind test (`/ab`) with the first user. POST.md draft is at `week2/POST.md` with TODOs for video, agent session, first user.
25
26 SUBMITTED Oct 16 2026 by the user (pasted by hand; the DevRelay MCP tools were not available in the session): https://dev.to/anishisbusy/replydesk-a-small-tuned-model-for-google-review-replies-and-the-test-it-lost-4ab6 . Demo video https://youtu.be/qZg3NvmLziI (made with /demo-video, output in ~/Code/Hackathons/demo-video-pipeline/output/replydesk). First user Pratik (HOP-In Cafe, Naya Raipur): blind test 15 prompted / 4 tuned / 0 same over 19 votes, so the human test also favored the prompted model. Live demo shop in the DB is now his cafe, not the garage. Agent session section was left out of the post.
27
$ cd /home/anish-kumar/Code/hacktoberfest/week2 2>/dev/null && ls && sed -n 1,80p PLAN.md | head -100; ls replydesk 2>/dev/null | head -30
CONTEST.md
DATASETS.md
design-options
PLAN.md
POST.md
replydesk
# Hacktoberfest Week 2 DEV Challenge — Project Plan
**Challenge:** Hacktoberfest Open-Source AI Challenge: Week 2 — **Side Hustle**
**Page:** https://dev.to/challenges/hacktoberfest-week2-2026-10-12
**Deadline:** Oct 18, 11:59 PM PDT = **Mon Oct 19, 12:29 PM IST**. Aim to publish **Sun Oct 18 night**.
**Contest details:** [CONTEST.md](CONTEST.md) · **Data research:** [DATASETS.md](DATASETS.md)
**Target categories:**
- **Main: Best Use of Tinker** ($200, 27 week 1 entries). $10 credit redeemed, no card.
- **Add-ons:** Best Use of Tiger Data ($100, 17) · Best Use of Backboard ($100, 23, $5 credit redeemed).
- **Stretch:** Best Use of Mastra ($100, 30), only if the drafting flow becomes a real Mastra workflow. Don't add it for show.
**Hard constraints (same as week 1):**
- Everything free, **no card** on any service.
- **No local LLM serving.** Laptop: i5-1235U, 7.5 GiB RAM, no GPU. Training runs on Tinker's GPUs; the laptop only sends batches.
- **New project only:** everything built Oct 12–18. Nothing reused from Nice Out except know-how.
- **Latest stack:** use the newest stable versions (checked Oct 13, see section 5). Re-check with `npm view` / PyPI on the day each package is installed.
---
## 1. The idea: ReplyDesk (working name)
**One line:** a small fine-tuned open model that writes Google review replies the way a good shop owner would: short, specific, human. Built for small shops that don't have time to answer every review.
**Who pays:** owners of small local businesses (auto shops, salons, dentists, cafés, repair shops). Replying to reviews helps their ranking and wins back unhappy customers, but most owners skip it or paste the same "Thank you!" everywhere. Price idea: **$5/month per shop** (or ₹299/month in India).
**Why it works as a business (for the post):** one reply on a fine-tuned 4B model costs about **$0.0003** on Tinker (≈400 input + 120 output tokens). A shop with 60 reviews a month costs under 2 cents to serve, so $5/month is nearly all margin. A big closed model would cost more per reply and lock the business into someone else's pricing.
**The story:** base models write replies that sound like a corporate apology letter: long, generic, "We sincerely apologize for any inconvenience". Real owners write two warm sentences that mention what the customer actually said. I fine-tuned a 4B open model on thousands of real owner replies, and it now writes like an owner, for a fraction of a cent, and the weights are mine.
### What the app does (MVP)
1. **Shop setup (2 min):** shop name, category, city, owner sign-off, and a few **shop facts** ("free re-check within 7 days", "ask for Priya at the front desk"). Facts go into **Backboard memory**. Optionally paste 5–20 of the owner's past replies; they go into **Tiger** for voice matching.
2. **Inbox:** paste a review (or import a CSV; the demo comes with sample reviews). Get a draft reply in about 2 seconds. Edit, copy, mark as sent. Quick buttons: shorter · warmer · offer to fix.
3. **Context for hard reviews:** for 1–3 star reviews, the app finds similar past reviews and how this owner (or good owners in the same category) replied, using **Tiger hybrid search** (keyword + vector). Those go into the prompt as examples.
4. **Guardrail:** the reply may only promise what's in the shop facts. No made-up refunds or discounts. A check step flags any promise that isn't in the facts.
5. **"Is it better?" page:** live numbers from the eval (base vs fine-tuned) plus a **blind A/B test**: the owner sees two replies to the same review, without knowing which is which, and picks the better one. Votes are stored in Tiger and become the "My First User" section.
**Not in scope:** auto-posting to Google. The Google Business Profile API needs approval, so the owner copies the reply. Say this honestly in the post.
---
## 2. Data: Google Local Reviews 2021 (UCSD)
Details in [DATASETS.md](DATASETS.md). Each review has the owner's reply (`resp`), and the metadata gives the business category.
**Pipeline** (`data/` folder, Python 3.12 + uv, polars):
1. Download `review-<State>.json.gz` + `meta-<State>.json.gz` for 3–4 small or mid-size states (e.g. Wyoming, Vermont, Montana, Oregon) to get variety in categories and voices.
2. Join reviews to metadata on `gmap_id` for business name and category.
3. **Filter:**
- review text ≥ 40 characters; reply 40–600 characters; English only
- drop **template replies**: any reply text the same business used more than twice, and replies that are mostly stock phrases
- drop replies posted long after the review (stale)
- keep categories that fit small shops (auto repair, salons, dentists, restaurants/cafés, home services, retail)
4. **Balance:** about 50% 1–3 star reviews (where a good reply matters most), 50% 4–5 star.
5. **Clean personal data:** swap reviewer names for the first name only or remove them; strip phone numbers and emails from replies.
6. **Split by business** (`gmap_id`), so no business appears in both train and test: **train ~6,000 · val 300 · test 300**.
7. Format as chat examples:
- system: short instruction ("You reply to Google reviews for {name}, a {category}. Write like the owner: short, warm, specific.")
- user: `Rating: {n}/5\nReview: {text}`
- assistant: the real owner reply
Credit the dataset (UCTopic, ACL 2022; Personalized Showcases, SIGIR 2023) in the README and post.
---
## 3. Fine-tune on Tinker (main track)
**Model: Qwen3.5-4B** (hybrid, 64K context, Apache 2.0).
- Train $0.737 / prefill $0.33 / sample $1.005 per million tokens.
- Why 4B: it's the smallest current model on Tinker. Small enough that the exported weights run on a laptop CPU (quantized), which is the open-weights story. And "a tuned 4B beats a prompted model 100× bigger" is a strong headline.
- Backup: Qwen3-8B (cheaper per token: train $0.44, sample $0.60) if the 4B results are weak.
- Qwen3-4B, Llama-3.2 and Llama-3.1 are retired on Tinker. Qwen3-8B-Base too.
**How:** `tinker` SDK + `tinker-cookbook` chat supervised-learning recipe (`cookbook/recipes/chat-sl`). LoRA (start at rank 32, the learning rate from the cookbook's helper), **thinking turned off** in the chat template so replies come out directly. 2 epochs, save a checkpoint every ~100 steps, pick the best by val loss plus a quick judge check.
**Budget (from the $10 credit):**
| Step | Tokens | Cost |
|---|---|---|
AGENTS.md
app
bun.lock
components
db
docs
embed-space
eslint.config.mjs
lib
LICENSE
ml
next.config.ts
next-env.d.ts
node_modules
package.json
postcss.config.mjs
README.md
scripts
tsconfig.json
tsconfig.tsbuildinfo
ReplyDesk is submitted (Oct 16). You can edit the post until Mon Oct 19, 12:29 PM IST, so there is time.
The weak spot I see is that the tuned model lost both tests. The LLM judge picked it first 5% of the time. In Pratik's blind test it got 4 of 19 votes.
Options:
1. Improve the model (v4). Check why people prefer the prompted model, probably too short or too generic. Retrain with better data filtering or an extra mix of longer, specific replies. This costs about $2 of the remaining Tinker credit. Best for the Tinker track, and risky only if v4 doesn't win.
2. Improve the product. Add things like CSV import, a better shop setup, tone buttons, and a check that flags promises not in the shop facts. This helps the Side Hustle angle and the Tiger and Backboard tracks.
3. Improve the post and proof. Get 1–2 more real users for the blind test. Add the agent session. Tighten the story around "honest loss, here is what I learned."
I'd start with option 1. A tuned model that beats the prompted one is the strongest fix, and the rest builds on it.
Which do you want, and what felt not good enough to you: the model, the app, or the post?
https://dev.to/t/hf26challenge/latest first start with scraping the data and see which people hasve gone with same track as mine (tinker)
$ S=/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad; mkdir -p $S/devto && cd $S/devto
for p in 1 2 3 4 5 6; do curl -s "https://dev.to/api/articles?tag=hf26challenge&per_page=100&page=$p" -o list$p.json; python3 -I -c "import json;print($p,len(json.load(open('list$p.json'))))"; done
1 100
2 100
3 100
4 100
5 100
6 100
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
p=7; while :; do curl -s "https://dev.to/api/articles?tag=hf26challenge&per_page=100&page=$p" -o list$p.json; n=$(python3 -I -c "import json;print(len(json.load(open('list$p.json'))))"); echo $p $n; [ "$n" = 0 ] && break; p=$((p+1)); [ $p -gt 30 ] && break; done
python3 -I - <<'E'
import json,glob
a={}
for f in glob.glob('list*.json'):
for x in json.load(open(f)): a[x['id']]=x
print(len(a))
import collections
print(sorted(collections.Counter(x['published_at'][:10] for x in a.values()).items()))
json.dump(list(a.values()),open('all.json','w'))
E
7 100
8 100
9 100
10 100
11 100
12 100
13 100
14 100
15 100
16 100
17 100
18 100
19 100
20 100
21 100
22 100
23 100
24 100
25 100
26 100
27 100
28 100
29 100
30 100
2504
[('2026-10-02', 1), ('2026-10-03', 77), ('2026-10-04', 307), ('2026-10-05', 313), ('2026-10-06', 48), ('2026-10-07', 107), ('2026-10-08', 92), ('2026-10-09', 152), ('2026-10-10', 168), ('2026-10-11', 439), ('2026-10-12', 218), ('2026-10-13', 81), ('2026-10-14', 140), ('2026-10-15', 169), ('2026-10-16', 184), ('2026-10-17', 8)]
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json
a=json.load(open('all.json'))
w=[x for x in a if x['published_at']>='2026-10-12']
print(len(w))
open('ids.txt','w').write('\n'.join(str(x['id']) for x in w))
E
mkdir -p body; cat ids.txt | xargs -P 8 -I{} sh -c '[ -s body/{}.json ] || curl -s "https://dev.to/api/articles/{}" -o body/{}.json'; ls body | wc -l
800
800
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re
rows=[]
for f in glob.glob('body/*.json'):
try: x=json.load(open(f))
except: continue
if 'body_markdown' not in x: continue
b=x['body_markdown']
t=len(re.findall(r'tinker',b,re.I))
if t==0: continue
sub=bool(re.search(r'best use of tinker|tinker track|prize categor[^\n]*tinker|tinker[^\n]*prize',b,re.I))
ft=bool(re.search(r'fine-?tun|lora|sft|tinker_cookbook|tinker-cookbook|import tinker|from tinker',b,re.I))
rows.append((t,sub,ft,x['user']['username'],x['title'],x['url'],x['public_reactions_count'],x['comments_count'],x['reading_time_minutes'],x['published_at'][:10],x['tags'] if 'tags' in x else ''))
rows.sort(reverse=True)
print(len(rows))
json.dump(rows,open('tinker.json','w'))
for r in rows: print(r[0],int(r[1]),int(r[2]),r[3],'|',r[4][:70],'|r',r[6],'c',r[7],'rt',r[8],r[9])
E
12
17 1 1 p0rt | It caught "partner" "pareja" in an IRS rule and missed "return" "devol |r 13 c 3 rt 13 2026-10-15
17 0 1 kishore_p_e1fbd59e95a81f6 | ScopeGuard: How I Built an NDA-Safe, Local Gemma 3 Agent That Saved $1 |r 0 c 0 rt 7 2026-10-15
15 1 1 hotragn | Flare: snap where you are, and it only lights if enough friends quietl |r 5 c 0 rt 28 2026-10-12
13 1 1 abbas7120 | I Built a Cold Email Generator That Never Needs a Prompt |r 0 c 0 rt 4 2026-10-14
9 1 1 anishisbusy | ReplyDesk: a small tuned model for Google review replies, and the test |r 0 c 0 rt 8 2026-10-16
6 0 1 coffeeandcommits | Dabba: a 4B model reads a tiffin kitchen's WhatsApp group and TabPFN s |r 5 c 0 rt 8 2026-10-15
5 1 1 scar3max | Prep Sheet: A Cafe Prep Planner |r 1 c 0 rt 5 2026-10-13
5 1 1 chestly_ace | DuckWalk: a rubber duck that won't talk to you until you go outside |r 1 c 0 rt 6 2026-10-12
2 0 1 memeshe | Confidant: a 1B Gemma drafts a therapist's session notes on her own la |r 0 c 0 rt 12 2026-10-13
2 0 1 kit57 | Hacktoberfest 2026 week 2 - CartaClara |r 10 c 2 rt 2 2026-10-14
1 0 1 abeera_lodhi | Clear Hour: I Committed an Air-Quality Forecast to Git Before the Day |r 6 c 2 rt 14 2026-10-12
1 0 0 krish007code | Invoice Sorter: a private expense tracker for freelancers that runs on |r 0 c 0 rt 2 2026-10-15
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re
rows=json.load(open('tinker.json'))
for r in rows:
if r[3] in ('memeshe','krish007code','kit57','abeera_lodhi','kishore_p_e1fbd59e95a81f6','anishisbusy'): continue
x=[json.load(open(f)) for f in glob.glob('body/*.json') if json.load(open(f)).get('url')==r[5]][0]
b=x['body_markdown']
print('=====',r[3],r[5],'len',len(b),'tags',x['tags'] if 'tags' in x else x.get('tag_list'))
print(b[:900].replace('\n',' '))
for m in re.finditer(r'[^\n]*tinker[^\n]*',b,re.I):
print(' >',m.group(0)[:260])
E
Exit code 1
Traceback (most recent call last):
File "<stdin>", line 5, in <module>
File "/usr/lib/python3.12/json/__init__.py", line 293, in load
return loads(fp.read(),
^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/decoder.py", line 337, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/decoder.py", line 355, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Some article fetches came back empty, probably rate limits. I'm refetching those, then reading the Tinker entries.
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
bad(){ python3 -I - <<'E'
import json,glob,os
b=[]
for f in glob.glob('body/*.json'):
try:
x=json.load(open(f)); assert 'body_markdown' in x
except Exception: b.append(os.path.basename(f)[:-5])
open('bad.txt','w').write('\n'.join(b)); print(len(b),'bad')
E
}
bad
for i in 1 2 3; do
cat bad.txt | xargs -P 2 -I{} sh -c 'rm -f body/{}.json; sleep 0.5; curl -s "https://dev.to/api/articles/{}" -o body/{}.json'
bad; [ ! -s bad.txt ] && break; sleep 20
done
Command did not complete within its 120s timeout and was moved to the background (ID: bsd7dveg0). Output is being written to: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/bsd7dveg0.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains /home/anish-kumar/Code/hacktoberfest; directory changes made by the backgrounded command do not apply to subsequent commands.
/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/bsd7dveg0.output
1 129 bad
2 71 bad
3 63 bad
4
Refetch is running in the background (129 empty, down to 63 so far). I'll continue when it finishes.
<task-notification>
<task-id>bsd7dveg0</task-id>
<tool-use-id>toolu_01EFR4yh3nXSCbwzYt1VL9xg</tool-use-id>
<output-file>/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/bsd7dveg0.output</output-file>
<status>completed</status>
<summary>Background command "cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
bad(){ python3 -I - <<'E'
import json,glob,os
b=[]
for f in glob.glob('body/*.json'):
try:
x=json.load(open(f)); assert 'body_markdown' in x
except Exception: b.append(os.path.basename(f)[:-5])
open('bad.txt','w').write('\n'.join(b)); print(len(b),'bad')
E
}
bad
for i in 1 2 3; do
cat bad.txt | xargs -P 2 -I{} sh -c 'rm -f body/{}.json; sleep 0.5; curl -s "https://dev.to/api/articles/{}" -o body/{}.json'
bad; [ ! -s bad.txt ] && break; sleep 20
done" completed (exit code 0)</summary>
</task-notification>
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
cat ../../tasks/bsd7dveg0.output | tail -3
python3 -I - <<'E'
import json,glob,re
rows=[]
for f in glob.glob('body/*.json'):
try: x=json.load(open(f)); b=x['body_markdown']
except Exception: continue
t=len(re.findall(r'tinker',b,re.I))
if t==0: continue
sub=bool(re.search(r'best use of tinker|tinker track|prize categor[^\n]*tinker|tinker[^\n]*prize',b,re.I))
rows.append((t,sub,x['user']['username'],x['title'],x['url'],x['public_reactions_count'],len(b),b))
rows.sort(key=lambda r:-r[0])
print(len(rows))
for r in rows:
print('=====',r[2],r[4],'tinker x',r[0],'sub',r[1],'react',r[5],'len',r[6])
print(r[3])
json.dump([r[:7] for r in rows],open('tinker2.json','w'))
E
59 bad
[exited with code 0]
14
===== p0rt https://dev.to/p0rt/it-caught-partner-pareja-in-an-irs-rule-and-missed-return-devolucion-an-offline-mt-7ge tinker x 17 sub True react 13 len 18800
It caught "partner" "pareja" in an IRS rule and missed "return" "devolución": an offline MT checker for translators
===== kishore_p_e1fbd59e95a81f6 https://dev.to/kishore_p_e1fbd59e95a81f6/scopeguard-how-i-built-an-nda-safe-local-gemma-3-agent-that-saved-1190-in-freelance-scope-creep-48e0 tinker x 17 sub False react 0 len 12240
ScopeGuard: How I Built an NDA-Safe, Local Gemma 3 Agent That Saved $1,190 in Freelance Scope Creep
===== nicebills https://dev.to/nicebills/i-read-321-touch-grass-entries-none-of-them-could-referee-a-game-of-simon-says-so-i-built-one-4if7 tinker x 16 sub True react 0 len 16604
I read 321 Touch Grass entries. None of them could referee a game of Simon Says. So I built one that can.
===== hotragn https://dev.to/hotragn/flare-snap-where-you-are-and-it-only-lights-if-enough-friends-quietly-say-yes-1jc8 tinker x 15 sub True react 5 len 42718
Flare: snap where you are, and it only lights if enough friends quietly say yes
===== abbas7120 https://dev.to/abbas7120/i-built-a-cold-email-generator-that-never-needs-a-prompt-90 tinker x 13 sub True react 0 len 6148
I Built a Cold Email Generator That Never Needs a Prompt
===== anishisbusy https://dev.to/anishisbusy/replydesk-a-small-tuned-model-for-google-review-replies-and-the-test-it-lost-4ab6 tinker x 9 sub True react 0 len 11257
ReplyDesk: a small tuned model for Google review replies, and the test it lost
===== coffeeandcommits https://dev.to/coffeeandcommits/dabba-a-4b-model-reads-a-tiffin-kitchens-whatsapp-group-and-tabpfn-says-how-many-thalis-to-cook-4dke tinker x 6 sub False react 5 len 11842
Dabba: a 4B model reads a tiffin kitchen's WhatsApp group and TabPFN says how many thalis to cook tomorrow
===== scar3max https://dev.to/scar3max/prep-sheet-a-cafe-prep-planner-3l0n tinker x 5 sub True react 1 len 6963
Prep Sheet: A Cafe Prep Planner
===== chestly_ace https://dev.to/chestly_ace/duckwalk-a-rubber-duck-that-wont-talk-to-you-until-you-go-outside-222j tinker x 5 sub True react 1 len 9136
DuckWalk: a rubber duck that won't talk to you until you go outside
===== memeshe https://dev.to/memeshe/confidant-a-1b-gemma-drafts-a-therapists-session-notes-on-her-own-laptop-and-nothing-leaves-the-5fnp tinker x 2 sub False react 0 len 17763
Confidant: a 1B Gemma drafts a therapist's session notes on her own laptop, and nothing leaves the room
===== kit57 https://dev.to/kit57/hacktoberfest-2026-week-2-cartaclara-m66 tinker x 2 sub False react 10 len 2209
Hacktoberfest 2026 week 2 - CartaClara
===== abeera_lodhi https://dev.to/abeera_lodhi/clear-hour-i-committed-an-air-quality-forecast-to-git-before-the-day-started-then-scored-it-in-p9n tinker x 1 sub False react 6 len 22321
Clear Hour: I Committed an Air-Quality Forecast to Git Before the Day Started, Then Scored It in Public
===== synthreaper https://dev.to/synthreaper/i-tried-to-fire-my-llm-api-open-models-passed-one-task-and-failed-the-other-1jde tinker x 1 sub True react 0 len 2933
I tried to fire my LLM API. Open models passed one task and failed the other.
===== krish007code https://dev.to/krish007code/invoice-sorter-a-private-expense-tracker-for-freelancers-that-runs-on-a-laptop-2eoj tinker x 1 sub False react 0 len 3074
Invoice Sorter: a private expense tracker for freelancers that runs on a laptop
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re
want=['p0rt','nicebills','hotragn','abbas7120','coffeeandcommits','scar3max','chestly_ace']
for f in glob.glob('body/*.json'):
try: x=json.load(open(f))
except: continue
u=x['user']['username']
if u not in want or not re.search('tinker',x['body_markdown'],re.I): continue
b=x['body_markdown']
print('\n=====',u,x['url'],'tags',x.get('tags'))
seen=set()
for m in re.finditer(r'tinker',b,re.I):
s=max(0,m.start()-250);
if any(abs(s-q)<400 for q in seen): continue
seen.add(s); print(' ...',b[s:m.start()+400].replace('\n',' '))
E
Exit code 1
Traceback (most recent call last):
File "<stdin>", line 6, in <module>
KeyError: 'user'
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re
want=['p0rt','nicebills','hotragn','abbas7120','coffeeandcommits','scar3max','chestly_ace']
for f in glob.glob('body/*.json'):
try: x=json.load(open(f)); b=x['body_markdown']; u=x['user']['username']
except: continue
if u not in want or not re.search('tinker',b,re.I): continue
print('\n=====',u,x['url'])
seen=[]
for m in re.finditer(r'tinker',b,re.I):
s=max(0,m.start()-250)
if any(abs(s-q)<500 for q in seen): continue
seen.append(s); print(' ...',b[s:m.start()+450].replace('\n',' '))
E
===== scar3max https://dev.to/scar3max/prep-sheet-a-cafe-prep-planner-3l0n
... local recent-average calculation, not TabPFN.** The TabPFN experiment currently runs on synthetic demo data; it has not been validated on a cafe's real sales history. Two other providers are available as explicitly opt-in Telegram workflows. `train-tinker` fine-tunes a LoRA adapter using the project's small synthetic sales-note examples; after configuring the returned model path, `/sales-note` sends a free-form note to Tinker and turns its structured extraction into a review draft. The owner must inspect and confirm the rows before they are saved. I verified the hosted path with a one-step fine-tune and a synthetic extraction note, but have not evaluated quality on real cafe notes; the resu
... ess, and may consume account quota. The demo sends synthetic features rather than a cafe owner's actual Telegram sales to TabPFN. The actual cafe-data workflow stays in local SQLite unless an owner explicitly uses `/sales-note` (sending that note to Tinker), `/voice` (sending the prep text to ElevenLabs), or enables Gemini briefing (sending the structured prep summary to Gemini). The next meaningful step is to evaluate a model on enough real, permissioned cafe history and connect it to `/prep` only if it improves on the simple local baseline. ## My Agent Session This is an expanded chronological reconstruction of the project decisions and implementation work; earlier turns were summarized
... xperiment described above. The Telegram real-sales forecast does not yet use TabPFN. - **Best Use of ElevenLabs** — The optional `/voice` command converts the prep brief into speech and sends the generated audio to the Telegram chat. - **Best Use of Tinker** — Tinker fine-tunes a LoRA adapter for structured sales-note extraction, exposed through the opt-in `/sales-note` workflow with a review-before-save step. The hosted path was smoke-tested on synthetic data, but I have not yet measured a clear performance, latency, or cost improvement over the rule-based extractor baseline; that comparison is still needed to fully meet this category's judging criterion. - **Best use of GitHub Copilot** —
===== coffeeandcommits https://dev.to/coffeeandcommits/dabba-a-4b-model-reads-a-tiffin-kitchens-whatsapp-group-and-tabpfn-says-how-many-thalis-to-cook-4dke
... rsh Khan's "1 dabba" to a paneer thali and watch the kitchen memory learn it. ## Code {% embed https://github.com/its-kumar-yash/dabba %} ## How I Built It Three open pieces, each doing one job. ### 1. The parser: Qwen3.5-4B + a LoRA trained on Tinker The hard part of a tiffin message is not the vocabulary, it is the timestamp. "kal" is a different date on every line, "parso" is two days from whenever the message was sent, and "Monday" from a Thursday night is in four days. A general model has to count; a model that has seen 1,400 of these does not. I generated the training set from a simulated kitchen rather than labelling by hand. Eighty customers with usual dishes, preferences, off
... sh, roman Hindi) with the WhatsApp export prefix. **The program that writes the message also writes the label**, so the labels are exact. A quarter of the training messages get the typos and lowercase that real thumbs produce. Training is the plain Tinker loop, three epochs, rank 16, 129 steps, under two dollars: ```python tc = service.create_lora_training_client(base_model="Qwen/Qwen3.5-4B", rank=16) renderer = get_renderer("qwen3_5_disable_thinking", tc.get_tokenizer()) for step in range(129): tc.forward_backward(batch(step), "cross_entropy") tc.optim_step(adam_params=tinker.AdamParams(learning_rate=lr(step))) sampler_path = tc.save_weights_for_sampler(name="dabba-v2").result().p
... was asked for 10–90% and covered 97% of outcomes, so it is wider than it claims. For a kitchen that is the right failure mode, but it is a failure mode. ### 4. Render One `render.yaml`, one Python web service, free tier. The parser is served from Tinker's sampling API because 512 MB cannot hold a 4B model; TabPFN runs through Prior Labs' API for the same reason. The same app can point at Ollama and the local TabPFN weights instead, one environment variable each; the parser backend and the local TabPFN mode are in the repo, though this week I only had time to measure the hosted path. ## Why Does Open Innovation Matter? **You can fine-tune.** No prompt teaches a frontier model that "parso
... bana de" — if it also makes the monthly bill. That is the next feature, and that is a yes with a condition, which is the best kind of first answer. ## My Agent Session Built with Claude Code over three evenings. The session did the simulator, the Tinker and TabPFN scripts, the app, the deploy, and caught two of its own labelling bugs by reading the error files. ## Prize Categories Thinking Machines (Tinker), Prior Labs (TabPFN), Render, Backboard.
===== abbas7120 https://dev.to/abbas7120/i-built-a-cold-email-generator-that-never-needs-a-prompt-90
...  Code {https://github.com/Abbas7120/ColdMailR} **How I Built It ?** **Model:** **Qwen3-8B**, an open-weight model, served through **Tinker** **(Thinking Machines)** for sampling. **Backend: Node.js + Express serves the UI and API**. It passes each request to a Python script that talks to Tinker. **Prompt design:** a strict system prompt (the sender is always the candidate, use only the given facts, copy numbers exactly) plus four few-shot examples from different fields (engineering, marketing, a fresher with no numbers, sales), so it works for any role. **Quality layer:** the
... with an open-weight model, I can choose the model, swap it, or later fine-tune it on good cold emails. I’m not locked to one vendor’s pricing, rate limits or policy changes. **Fine-tuning:** a closed chat API gives me a prompt box. An open model on Tinker gives me a path to train a model that writes cold emails specifically, which is the real moat for a product like this. **Trust:** the whole pipeline, including the prompt and the validator, is open on GitHub, so users and contributors can inspect exactly how emails are produced and add rules. **Accessibility:** students can fork it, run it, and improve it without needing a paid closed API. **Prize Categories** **Best Use of Render:**
... eeding a paid closed API. **Prize Categories** **Best Use of Render:** Render is the **production runtime** for the whole app. One Render web service runs the Node/Express server, the UI, and the Python AI pipeline: prompt building, tokenization, Tinker sampling and output validation. The live demo at https://coldmailr.onrender.com/ is that service. No Docker, a **plain Render** build. The Node runtime’s build command creates a Python virtual environment, installs the Python dependencies, and pre-downloads the Qwen tokenizer, so users don’t wait on a download. The start command is just node server.js. **Secrets** stay out of the repo. The Tinker API key lives in Render’s environment varia
... ry push to main redeploys, so contributors’ pull requests ship quickly. **Mixed-language stack on one service** , Render runs Node and Python side by side without separate infrastructure, which keeps hosting simple for a solo builder. **Best Use of Tinker:** Tinker is the only model backend. Every email comes from Qwen3-8B (open-weight), sampled through Tinker’s ServiceClient and SamplingClient. There is no closed API anywhere in the pipeline. **Best-of-N** sampling in one call. Each request asks Tinker for 3 candidate emails with num_samples=3. A validator then scores them for format, length, company mention, cliché phrases and invented numbers, and returns the cleanest one. Tinker’s batc
... leanest one. Tinker’s batched sampling makes this cheap enough to run on every click. **Prompt control at the token level**. I build the Qwen chat template myself (system rules plus four few-shot examples), tokenize it locally, and pass token IDs to Tinker. This gives exact control over stop tokens and the empty-think block, which a hosted chat endpoint wouldn’t expose. **Reliability**, not just generation. Tinker supplies the candidates and my validation layer decides what the user sees. The result is that the app never shows invented numbers or fake company claims. Built to grow into fine-tuning. The validator’s accepted outputs are exactly the training data needed to fine-tune the model o
===== p0rt https://dev.to/p0rt/it-caught-partner-pareja-in-an-irs-rule-and-missed-return-devolucion-an-offline-mt-7ge
... ught "partner" → "pareja" in an IRS rule and missed "return" → "devolución": an offline MT checker for translators' published: true description: "An offline checker that ranks machine-translated segments by the risk of a major meaning error: a $1.22 Tinker LoRA, Gemma 4 on DigitalOcean, Sentry tracing." tags: devchallenge, hf26challenge, ai, machinelearning cover_image: https://sergei-parfenov.com/assets/covers/secondread-train-shadow.webp canonical_url: https://sergei-parfenov.com/blog/it-caught-partner-pareja-and-missed-return-devolucion/ --- *This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle](https://dev.to/challenges/hacktoberfest-week2-2026-10-12)
... or meaning error** (wrong sense, dropped negation, wrong number, omission or addition), flags the risky ones and sorts the job so you re-read the riskiest 10% first. Under the hood: Qwen3.5-4B at 4-bit (2.7 GB), a 36 MB LoRA adapter I fine-tuned on Tinker for $1.22, and rule checks for numbers and negation; a 39-segment page takes about 6 s on an M4 Pro, offline. On WMT English→Spanish test it ranks errors as well as Gemma 4 31B (AUROC 0.815 vs 0.807); on ten real public documents Gemma ranks better (0.940 vs 0.910), and my default flag line finds only a fifth of the errors. **Who would pay:** agencies, then freelancers, who already buy QA tools such as Xbench and Verifika. WMT text is res
... /github.com/P0rt/secondread (MIT): everything from data prep to the UI, with the command behind every number below in `docs/RESULTS.md` and `docs/REALDOCS_GOLD.md`. ## How I Built It I built it with Claude Code, starting with go/no-go checks: does Tinker train this model, does the adapter survive GGUF conversion, does Sentry receive zero text?  (Apache-2.0): every error in WMT24 English→Spanish (9,330 segments) and WMT22 English→Russian (19,292), marked by human annotators with an MQM c
... from always-OK to always-Major within three steps; 20-step probes picked 2e-4. The run took 9 min 24 s and **$1.22**: $1.07 for 1,452,983 training tokens, $0.15 for dev scoring. Dev AUROC rose from 0.552 to 0.739. Locally, the 4-bit model ranks like Tinker (Spearman 0.989).  ### Results on WMT test Unseen documents (25 en-es, 33 en-ru), scored by the model alone. AUROC is the chance a segment with a major error ranks above a clean one (0.5 is a coin flip); PR-AUC rewards a clean top of t
... U.S. federal and EU pages with an official Spanish version (IRS, FDA, CDC, SSA, USCIS, EU air passenger rights and more) and translated their 720 segments twice: with OPUS-MT, an open 2020 model, locally, and with DeepSeek-V3.1, an open 671B LLM, on Tinker. Nothing was tuned on these 1,440 translations. **The labels.** Three blind AI annotators (an MQM rater, a post-editor and a Spanish-only reader) labelled each translation Major or OK; two of three decide. None saw the MT system or any score. On 247 WMT24 units with professional labels, a first version of the panel reached F1 0.67 and Cohen's kappa 0.59. Labels, scorer and flag lines were fixed before any detector was scored. **The surpr
... T and CometKiwi, are CC BY-NC-SA 4.0: a paid tool needs Unbabel's permission. Qwen3.5-4B, Gemma 4, OPUS-MT and Google's annotations are Apache-2.0, so I could train my own and translate the real pages locally. The caveat: training wasn't local, but Tinker and DigitalOcean saw only public text. Tinker billed $6.46 in all: $6.21 for Qwen3.5-4B (four training runs, probes and evaluations), $0.23 for the DeepSeek-V3.1 translations and $0.02 for checkpoint storage. DigitalOcean cost at most $1.64 at list price, and Sentry's free plan covered tracing. ## Prize Categories - **Best Use of Tinker.** The shipped model is a $1.22 Tinker LoRA, run locally as a 36 MB GGUF. Over the untuned base model
... $1.22 Tinker LoRA, run locally as a 36 MB GGUF. Over the untuned base model it lifts WMT test AUROC from 0.776 to 0.815 (en-es) and 0.666 to 0.801 (en-ru), and real-page AUROC by 0.071, level with Gemma 4 31B on WMT at $0 per segment. The v2 and v3 Tinker runs decided what ships, and Tinker sampled the DeepSeek-V3.1 translations. - **Best Use of DigitalOcean.** Gemma 4 31B on serverless inference is the cloud baseline everywhere (12,671 calls) and generated the synthetic errors, checking v3's (12,727 calls), for at most $1.64; App Platform hosts the demo: https://secondread-e49hx.ondigitalocean.app. - **Best Use of Sentry Agent Tracing.** One AI-agent trace per review with tokens, latency,
... CDC and MedlinePlus (U.S. federal works, public domain or free to copy) and Regulation (EC) No 261/2004 from EUR-Lex (reuse under Decision 2011/833/EU). The Spanish shown is machine translation; no endorsement is implied. - **Code:** `scripts/train_tinker.py` is adapted from `supervised/train.py` in Thinking Machines' tinker-cookbook (Apache-2.0). The GGUF export uses llama.cpp's `convert_lora_to_gguf.py` (MIT); llama-server runs the model. - **Prompt and models:** GEMBA-MQM (Kocmi & Federmann, 2023, CC BY-SA 4.0; adapted, with attribution, in `prompts/gemba_mqm.txt`); Qwen3.5-4B (Apache-2.0) from unsloth's GGUF; Gemma 4 31B (Apache-2.0); OPUS-MT en-es (Tiedemann & Thottingal, 2020, Apache-
===== hotragn https://dev.to/hotragn/flare-snap-where-you-are-and-it-only-lights-if-enough-friends-quietly-say-yes-1jc8
... ts) | The six "Since launch" totals the site shows, with no ids, names or groups | | [apps/web/src/lib/conditions/rules.ts](https://github.com/Hotragn/flare/blob/main/apps/web/src/lib/conditions/rules.ts) | The phone's conditions parser, taught with Tinker, a service for training small models | | [packages/shared/src/plaintext.ts](https://github.com/Hotragn/flare/blob/main/packages/shared/src/plaintext.ts) | The 200-byte text for SMS and satellite texting | ## Why we stay in  *Time with friends went f
... ed | Proof | | --- | --- | --- | --- | --- | | Photo covered by words on the invite | 73% | 0% | New gate on 3 viewports | [f9a7653](https://github.com/Hotragn/flare/commit/f9a7653) | | Phone rules reading new phrasings (blind set) | 20/40 | 30/40 | Tinker's phrasing families and the rules' own misses taught the rules | [conditions-tinker.md](https://github.com/Hotragn/flare/blob/main/evals/reports/conditions-tinker.md) | | Gemma 4 E2B in the browser, photo scenes | 29/40 points | not shipped | The model stays off the phone | [measured-not-shipped.md](https://github.com/Hotragn/flare/blob/main/docs/ai-runtime/measured-not-shipped.md) | | Sounds of the spot, voices caught | 8/12 | cut | Faile
... ing against the ICD | Engine, backend, hub, PWA, frontend; integration merge at 1:55 AM | End-to-end suites | | 2: deploy and partners | Oct 11, 7:05 AM to 12:42 PM | Live, and the partner tech wired in | Render deploy (10:51), Mastra agent (10:54), Tinker rules (12:21), AI Studio key (12:42) | Fresh-eyes round 1 | | 3: clarity | Oct 11, 1:09 PM to 5:14 PM | A stranger understands it in a minute | Rounds 2 to 5 fixes, ElevenLabs compass voice (2:12), axe 0 and Lighthouse 100 (3:33) | Rounds 2 to 5 | | 4: field test 1 | Oct 11, 8:10 PM to 10:38 PM | Fix what real friends broke | All 6 bugs, plus the service worker (9:54) | Round 6 | | 5: freeze | Oct 11, 10:55 PM to 11:57 PM | Round 6 fixes,
... r-agent-hub.md](https://github.com/Hotragn/flare/blob/main/evals/reports/ladder-agent-hub.md). Two runs of 13 asks, agent vs old route: 5.1 vs 5.5 s p95, then 5.6 vs 5.3 s. The weather tool matched the plan's time window 7/7. {% enddetails %} #### Tinker Tinker is a service for training small models. I used it to teach the phone's conditions parser (the rules that read "after 10" or "only with Amor") more phrasings. I trained a small LoRA (a cheap add-on layer for a model) without owning a GPU and compared it to the rules on a blind set. {% details Tinker: what I measured %} [ml/tinker/](https://github.com/Hotragn/flare/tree/main/ml/tinker) holds the data generator, the rules bridge and
... gn/flare/tree/main/ml/tinker) holds the data generator, the rules bridge and a fresh held-out set; the model card is [model-card-conditions-lora.md](https://github.com/Hotragn/flare/blob/main/docs/ml/model-card-conditions-lora.md). From [conditions-tinker.md](https://github.com/Hotragn/flare/blob/main/evals/reports/conditions-tinker.md). A $2.40 Qwen3.5-4B LoRA read 38 of 40 new phrasings. It's too big for a phone, so it didn't ship. Instead its phrasing families and the rules' own misses taught the rules ([6a73941](https://github.com/Hotragn/flare/commit/6a73941)), with the gain in the [losses table](#losses-and-baselines). The rules still run on the phone in under a millisecond. {% endde
... ter I sent it, and we met in person. [Details](#testing-it-with-friends) - **Best Use of Gemma:** Gemma 4 turns a photo of where you are into a six-word invite, on my own laptop: 39 of 40 points on 20 test photos. [Details](#gemma-4) - **Best Use of Tinker:** Tinker helped the phone understand more ways friends say "I'm in if…": a $2.40 LoRA read 38 of 40 new phrasings. [Details](#tinker) - **Best Use of ElevenLabs:** ElevenLabs gives the compass a warm voice that plays offline and sends nothing: 54 lines, recorded once. [Details](#elevenlabs) - **Best Use of Mastra:** Mastra lets the Plan agent check sunset, weather and nearby places: the weather tool matched the plan's time window 7/7. [De
===== nicebills https://dev.to/nicebills/i-read-321-touch-grass-entries-none-of-them-could-referee-a-game-of-simon-says-so-i-built-one-4if7
... h by comparing each one with their line-up photo, and a referee looks at each player's own crop and answers one yes/no question: *"Look at the person in this photo. Is that person standing on one foot?"* The referee is a **Qwen3.5-4B I fine-tuned on Tinker** for exactly that question, or Liquid's **d1** decision model if you want the whole game on your own laptop. It returns a probability, the game applies the rules, and Simon calls out who's out. Last one standing wins. The screen is for setup only: aim, line up, press Start. The rest is voices, laughing, and people wobbling on one leg. Playing alone works too: whoever is in the picture is the player. It's for anyone with a phone, a lapto
... the right person), with no identity swaps. The one miss: a player in a deep squat, straight after the swap, scored 0.30 against her standing photo and was reported "not seen", which the rules turn into a warning, not an elimination. ### Best Use of Tinker: fine-tuning the referee The referee answers one question: *is this person, in this crop, doing this pose?* On the 8 GB, no-GPU box I built this in, the d1 that fits (`d1-omni-600M`) got that right **63%** of the time on a hand-checked test set, and most of its mistakes were the worst kind for a referee: calling people out for poses they weren't doing. So I fine-tuned a referee on **Tinker**: a rank-32 LoRA on **Qwen3.5-4B** (open weights
... per command (both wrists 0.3 torso-lengths above the nose with arms straight means reaching for the sky, and so on). Ambiguous people are dropped, and each person is cropped exactly the way the game crops a player. The untuned **Qwen3.6-35B-A3B** on Tinker then cleaned the labels: a train/val example was kept only if it agreed with the rule. The test set never touched the rules or the teacher. I looked at all 262 candidate crops, flipped 15 labels and dropped 40, leaving **222 test crops from 210 photos never used for training.** **The numbers** (threshold 0.5 for every model, six pose commands, [`bench/results-tinker.md`](https://github.com/nice-bills/simon-sees/blob/main/bench/results-tin
... model with ~9x the parameters, it's ahead but not significantly (p = 0.08). One caveat: I chose the near-miss categories after looking at the first run's test errors, so the test gain is probably a little optimistic. **Speed and cost.** One batched Tinker call per round takes ~2.9 s from my sandbox whether it's 1, 3 or 6 players (480 ms per crop at 6). `d1-omni-600M` on that CPU takes ~2.7 s *per player*, so a round of 6 takes 16.5 s. At Tinker's list prices a crop costs about $0.00006, a 10-round game with 6 friends is under half a cent, and both training runs plus every evaluation came to about $2. **What didn't work.** "Cover your mouth" and "wave" had rules too, but on COCO a hand near
... the model the wrong thing. The rarest poses still have thin data (9 training positives for hands-on-head), and `arms_up` didn't improve at all: people mid-jump with their arms up are its remaining errors. **Yours to keep.** The LoRA downloads from Tinker (`scripts/export_referee.py`) and merges into a plain Qwen3.5-4B checkpoint for transformers or vLLM, so the referee doesn't have to depend on any service. That local path needs more RAM than my 8 GB box, so it's documented in `docs/tinker.md` and marked untested. ### The fully local referee: a decision model, not a chatbot Without a Tinker key, the game falls back to **[Liquid AI's d1](https://huggingface.co/LiquidAI/d1-3B)**, an open-w
... ber to threshold. Every seen player's crop goes into one batched request per frame. For "freeze", d1 gets the same player's BEFORE and AFTER crops and one question: *"These two photos show the same person a moment apart. Did that person move?"* (The Tinker referee sees one crop at a time, so with it, freeze is judged by a pixel-difference check inside the player's box, labelled `FALLBACK` in the log.) In an early whole-frame test on the CPU box, a generative VLM (`LFM2.5-VL-450M`) shown the *same* photo twice said the person had moved (2 of 2), while d1 correctly said no (2 of 2). For a freeze referee, that's the error that matters. The larger `d1-3B` (6.2 GB in bf16) didn't fit in that box
... ion Matter? **It's a camera pointed at your friends.** That sentence is why this had to be built on open models. With the local d1 referee, no frame leaves devices you own: no cloud vision API, no account, nothing stored unless you opt in. With the Tinker referee, the only thing that leaves the laptop is each player's dimmed crop, sent for one yes/no answer to a model I trained and control, and you can take the weights home with `export_referee.py`. I wouldn't point a closed vision API at a kids' birthday party, and I wouldn't expect anyone else to. **I could fix the referee instead of prompting around it.** An off-the-shelf model said "you did it" to a crouched skateboarder and a cyclist.
... ave good signal.** With d1, the weights download once. After that the phone joins the laptop's hotspot with mobile data off, and the game runs in a field with zero bars. **It's cheap enough to play all afternoon.** Locally it costs some battery. On Tinker it's under half a cent for a 10-round game of six. Neither is a reason to stop playing. **I could look inside and swap parts.** The d1 weights *and* its inference code are on Hugging Face; reading its `runner.py` is how I learned to batch every player into one request per frame. Swapping the referee, the segmenter or the embedder is one flag, and every swap is logged. A licence note, because "open" has fine print: the code is MIT and Qwe
... r Meta's own licences. The referee's training images come from COCO/Flickr under their own CC licences, many non-commercial, so the fine-tuned weights aren't in the repo. The README spells all of it out. ## Prize Categories - Overall - Best Use of Tinker: the referee is a Qwen3.5-4B LoRA fine-tuned on Tinker, scoring 93.2% against 87.4% for the untuned model and 63.1% for the local `d1-omni-600M` baseline on 222 hand-checked crops, with false "you did it" calls cut from 27 to 9. Prop the phone up, step back, and let Simon be the one everybody argues with.
===== chestly_ace https://dev.to/chestly_ace/duckwalk-a-rubber-duck-that-wont-talk-to-you-until-you-go-outside-222j
... held myself to the whole build: minimize time looking at a screen. If a feature didn't push work onto the AI, it got cut. Underneath, it's a durable Temporal workflow, every stage traced in Sentry, and the duck's voice is a 4B model I fine-tuned on Tinker until it matched a much larger cloud model on this specific job. ## Demo I'm writing this at 2am, and the whole point of the project is a real walk outside, so I wasn't going to fake that with a clip filmed at my desk. What I can show instead is something that doesn't need daylight: a real crash mid-conversation, and Temporal picking it back up without losing a word. ```plaintext Duck: It's intermittent. What distinguishes the times it
... ving the desk feel safe: the model writes down where you were so you don't have to. The duck's own voice needed something narrower than general-purpose chat: always exactly one question, never the fix, plain and short. Gemma isn't available through Tinker, so I fine-tuned Qwen3.5-4B on filtered teacher dialogues instead. The untuned model passed a frozen 8-criteria rubric 68% of the time; after tuning, 98%. Measured against the deployed cloud Gemma duck (80%), I'd call that equal quality from a 4B model, not better. A second, independent judge put the two within noise of each other, and I wrote up that caveat along with the rest of the methodology in [eval/results.md](https://github.com/che
... s never the content of what you said, just how long each stage took. Being able to swap the pieces is also what let me actually measure anything instead of trusting a vendor's claim. I pointed the duck's backend at Ollama, a local llama-server, and Tinker's cloud, one env var at a time, and compared them directly. The fine-tuning number above only exists because Tinker made training and serving a small open model cheap enough to run that comparison for real. And it costs nothing to run every day, which matters for something meant to interrupt you several times a shift. A tool you hesitate to use because of a bill is a tool you turn off. ## Results The fine-tuning went from 68% to 98% on
... no feature engineering or tuning. I'll say the caveat up front: real held-out metrics aren't collected yet, for the reason above. The classifier, its Sentry instrumentation, and its failure handling are all real and tested regardless. **Best Use of Tinker.** Took a 4B model from 68% to 98% on an 8-criteria behavioral rubric, with real before-and-after numbers and a second judge checking the first one's work, not just a vibe. **Best Use of Temporal.** The crash-and-resume up top isn't a demo trick. It's the actual thing that turns a phone losing signal for an hour into a non-event instead of a risk of losing the whole walk. **Best Use of Sentry Agent Tracing.** Full per-stage tracing is wh
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
Scraped the hf26challenge tag: 2,504 posts, 800 from Oct 12 onward (week 2). I read all but 59 of those 800, because dev.to blocked those fetches. 14 posts mention Tinker, and 5 of them are real Tinker contenders besides yours.
| Author | Project | Tinker use | Result vs untuned |
|---|---|---|---|
| nicebills | Simon Says referee | Qwen3.5-4B LoRA, yes/no pose check | 93.2% vs 87.4% untuned. False calls cut from 27 to 9. 222 hand-checked test crops. About $2. |
| p0rt | SecondRead, MT error checker | Qwen3.5-4B LoRA, $1.22 | AUROC 0.815 vs 0.776 base. Ties Gemma 31B on WMT, loses on real docs, and says so. Ran 4 runs, $6.46 total. Also targets DigitalOcean and Sentry. |
| chestly_ace | DuckWalk | Qwen3.5-4B LoRA for the duck's voice | Rubric 68% → 98%. Matches a cloud Gemma duck (80%), "not better", with a second judge. |
| coffeeandcommits | Dabba | Qwen3.5-4B LoRA, WhatsApp order parser | 1,400 synthetic examples, 129 steps, under $2. Exact labels. |
| hotragn | Flare | $2.40 LoRA, read 38/40 phrasings | Not shipped. Used only to teach rules. Weak Tinker entry. |
Weaker ones: abbas7120 (prompting only, no tuning), scar3max (one-step smoke test, no eval).
What this means for you
- The strong entries all fine-tune on a task with exact labels (classification, extraction, yes/no). Tuned beats untuned with a clean number.
- ReplyDesk is open-ended writing, and the tuned model lost both the judge test and Pratik's test. Next to the others, that is the weakest result.
- The honest framing is a plus, since p0rt and chestly_ace do the same. But "tuned loses" still scores below "tuned wins by 6 points".
- Dabba and Simon Says are also web apps with a clear "who pays".
Fix to try first: find one measurable thing the tuned model does better, and train for it. Examples: no invented promises, reply length, no stock phrases (already 2% vs the base model). Or add a small exact-label task, like detecting what the review complains about. Then retrain (v4) so a table can show tuned beating the base on that.
Do you want me to open week2/ml and the eval files to find where v3 loses and what a v4 could target? I can also retry the 59 missing posts first.
wait the data you say is based on 59 not 800 right
No. It's based on 741 of the 800 posts. The 59 are the ones that failed to download, and those are the only gaps.
hf26challenge and published Oct 12 or later.I can retry those 59 with longer pauses to close the gap. Want me to?
yes retry the 59
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
cat > ../retry.sh <<'E'
cd "$(dirname "$0")/devto"
bad(){ python3 -I - <<'P'
import json,glob,os
b=[]
for f in glob.glob('body/*.json'):
try:
x=json.load(open(f)); assert 'body_markdown' in x
except Exception: b.append(os.path.basename(f)[:-5])
open('bad.txt','w').write('\n'.join(b)); print(len(b),'bad')
P
}
bad
for i in 1 2 3 4 5 6; do
[ ! -s bad.txt ] && break
while read id; do rm -f body/$id.json; curl -s "https://dev.to/api/articles/$id" -o body/$id.json; sleep 3; done < bad.txt
bad; sleep 30
done
E
bash ../retry.sh
Command running in background with ID: bnrwy40ln. Output is being written to: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/bnrwy40ln.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
Session cwd remains /home/anish-kumar/Code/hacktoberfest; directory changes made by the backgrounded command do not apply to subsequent commands.
Retry is running in the background, one request every 3 seconds. I'll report when it finishes.
coul you ptovide me links to all tinker based project submisisons
Links to the 14 posts that mention Tinker, from the 741 I could read. The 59 retries are still running, so I'll add anything new when they finish.
Entered in Best Use of Tinker
- nicebills, Simon Says referee: https://dev.to/nicebills/i-read-321-touch-grass-entries-none-of-them-could-referee-a-game-of-simon-says-so-i-built-one-4if7
- p0rt, SecondRead: https://dev.to/p0rt/it-caught-partner-pareja-in-an-irs-rule-and-missed-return-devolucion-an-offline-mt-7ge
- hotragn, Flare: https://dev.to/hotragn/flare-snap-where-you-are-and-it-only-lights-if-enough-friends-quietly-say-yes-1jc8
- chestly_ace, DuckWalk: https://dev.to/chestly_ace/duckwalk-a-rubber-duck-that-wont-talk-to-you-until-you-go-outside-222j
- scar3max, Prep Sheet: https://dev.to/scar3max/prep-sheet-a-cafe-prep-planner-3l0n
- abbas7120, ColdMailR (prompting only): https://dev.to/abbas7120/i-built-a-cold-email-generator-that-never-needs-a-prompt-90
- synthreaper, "I tried to fire my LLM API": https://dev.to/synthreaper/i-tried-to-fire-my-llm-api-open-models-passed-one-task-and-failed-the-other-1jde
- yours: https://dev.to/anishisbusy/replydesk-a-small-tuned-model-for-google-review-replies-and-the-test-it-lost-4ab6
Use Tinker but don't name it as a prize category
- coffeeandcommits, Dabba (names "Thinking Machines (Tinker)" in its prize list): https://dev.to/coffeeandcommits/dabba-a-4b-model-reads-a-tiffin-kitchens-whatsapp-group-and-tabpfn-says-how-many-thalis-to-cook-4dke
Brief or passing mentions (1 to 2 times)
- kishore_p_e1fbd59e95a81f6, ScopeGuard (17 mentions, but no tuning): https://dev.to/kishore_p_e1fbd59e95a81f6/scopeguard-how-i-built-an-nda-safe-local-gemma-3-agent-that-saved-1190-in-freelance-scope-creep-48e0
- memeshe, Confidant: https://dev.to/memeshe/confidant-a-1b-gemma-drafts-a-therapists-session-notes-on-her-own-laptop-and-nothing-leaves-the-5fnp
- kit57, CartaClara: https://dev.to/kit57/hacktoberfest-2026-week-2-cartaclara-m66
- abeera_lodhi, Clear Hour: https://dev.to/abeera_lodhi/clear-hour-i-committed-an-air-quality-forecast-to-git-before-the-day-started-then-scored-it-in-p9n
- krish007code, Invoice Sorter: https://dev.to/krish007code/invoice-sorter-a-private-expense-tracker-for-freelancers-that-runs-on-a-laptop-2eoj
The ones that matter to you are the first four plus Dabba. They show a measured gain from tuning.
yeah all of them are in participating ion tinker track, if they mention using tinker platform in their post they qualify for that partner program
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re
want=['kishore_p_e1fbd59e95a81f6','memeshe','kit57','abeera_lodhi','krish007code','synthreaper']
for f in glob.glob('body/*.json'):
try: x=json.load(open(f)); b=x['body_markdown']; u=x['user']['username']
except: continue
if u not in want or not re.search('tinker',b,re.I): continue
print('\n==',u)
seen=[]
for m in re.finditer(r'tinker',b,re.I):
s=max(0,m.start()-120)
if any(abs(s-q)<300 for q in seen): continue
seen.append(s); print(' ..',b[s:m.start()+200].replace('\n',' '))
E
== abeera_lodhi
.. b/main/docs/licenses/PriorLabs-TabPFN-v2-LICENSE.txt). Hollmann et al., *Nature* 2025, doi:10.1038/s41586-024-08328-6. Tinker was explored as an optional stretch (two LoRA training clients created as a capability probe), but nothing was trained or sampled with it, so it is **not** claimed as a result here. * * * ##
== synthreaper
.. ed-metrics rule. ## Prize Categories - **Best Use of Gemma:** 2B runs locally; 9B is the open teacher - **Best Use of Tinker:** LoRA training in 85s for $1.03
== kishore_p_e1fbd59e95a81f6
.. a** agent retrieves your signed Statement of Work (SOW) clauses from **MongoDB Atlas**, evaluates the request using a **Tinker fine-tuned Google Gemma 3 4B** model, calculates the exact unbilled revenue at risk, and drafts a diplomatic, ready-to-send Change Order email—complete with itemized line items and a 1-click ap
.. base models tend to summarize the request rather than aggressively hunting for contractual boundary violations. Using **Tinker**, I fine-tuned a LoRA adapter (`Rank 16, Alpha 32`) on Gemma 3 4B specifically trained to detect conversational scope traps (*"should just be a simple toggle"*), cite exact SOW exclusions, and
.. rgins: | Cost & Risk Dimension (10,000 Audits / Mo) | Closed Frontier API (GPT-4o / Claude) | ScopeGuard (Gemma 3 4B + Tinker LoRA) | | :--- | :--- | :--- | | **Monthly Inference API Cost** | ~$180.00 / month | **$0.00 marginal token cost** | | **Annual Agency API Spend** | ~$2,160.00 / year | **$0.00 (Local) or Flat
.. 100% Air-Gapped / NDA-Safe** | | **JSON Schema Reliability** | Prone to markdown wrapper leaks | **100.0% adherence via Tinker LoRA** | --- ## Demo ### 1. Live Scope Auditor Dashboard When a client asks for a *"quick role-based admin portal before launch,"* ScopeGuard flags it as `OUT_OF_SCOPE_CREEP` (Risk Score: **
.. o[("MongoDB Atlas SOW Collection")] Mongo --> Tool Tool --> Mastra Mastra --> Gemma["Fine-Tuned Gemma 3 4B (Tinker LoRA)"] Gemma --> AuditResult["Scope Verdict + Itemized Quote + Pushback Email"] AuditResult --> UI ``` ### How Each Partner Tool Powers the Workflow: * **Google Gemma 3 4B ($200 Tie
.. t reasoning engine via an OpenAI-compatible Ollama/vLLM endpoint (`@ai-sdk/openai`), guaranteeing 100% NDA privacy. * **Tinker ($200 Tier):** Used to fine-tune Gemma 3 4B (`scripts/generate-tinker-dataset.mjs` & `scripts/evaluate-tinker.mjs`) on freelance SOW clauses vs. conversational scope-creep scenarios. * **Mastra
.. ready `render.yaml` Blueprint for one-click cloud deployment. --- ## Model Fine-Tuning & Evaluation: Base Gemma 3 vs. Tinker LoRA A generic model often falls for polite phrasing like *"Since the styles are already done, shouldn't take more than 10 minutes, right?"* To solve this, I built a domain-specific training
.. al-world freelance client messages** across three classes: `OUT_OF_SCOPE_CREEP`, `AMBIGUOUS_TRAP`, and `IN_SCOPE`. ### Tinker Benchmark Results (`node scripts/evaluate-tinker.mjs`) | Evaluation Metric | Base Gemma 3 4B | Tinker Fine-Tuned LoRA | Delta / Gain | Real-World Impact | | --- | --- | --- | --- | --- | | **P
.. —meaning you can clone and test all three preset scenarios, inspect the live OpenTelemetry trace waterfall, and run the Tinker evaluation benchmark right out of the box: ```bash # 1. Clone and install dependencies git clone [https://github.com/kishorekrrish3/ScopeGuard.git](https://github.com/kishorekrrish3/ScopeGuard
.. run dev # 3. Optional: Seed MongoDB Atlas with SOW contracts curl -X POST http://localhost:3000/api/seed # 4. Run the Tinker LoRA vs. Base Gemma 3 evaluation suite node scripts/evaluate-tinker.mjs ``` Thanks for reading—and here's to never building an unpaid "quick 5-minute admin portal" again! 🛡️
== memeshe
.. cal text, verifiable without an account. It stays unclaimed because metadata tracing is not what the category asks for. Tinker is wired too: a 40-step LoRA on `Qwen/Qwen3.5-4B` over the note-structuring task, trained on the deterministic floor's outputs and scored on six held-out recaps with the same scorer the committ
== krish007code
.. lock-in:** I can swap Qwen, Llama or Phi without rewriting anything, and no vendor can change the pricing under me. - **Tinkering:** because I run the model myself, I could see exactly where it failed (categories) and fix it with prompts plus rules. With a closed API I'd be stuck with whatever it returned. - **Honest l
== kit57
.. t CartaClara uses an open-weight language model to suggest dish translations and allergen tags. The project includes a Tinker fine-tuning pipeline for Spanish dishes, plus mock and OpenAI-compatible backends—including a local open-weight model served through Ollama. The prompt and output format are shared between infe
.. examples, evaluation approach, and application code available for others to inspect and improve. ## Prize Categories Tinker, BACKBOARD
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
You're right, so I re-read the passing mentions. Mine and 10 others use the Tinker platform, which makes 11 competitors. Two of the 14 don't.
Use the Tinker platform (11 competitors)
1. nicebills, Simon Says referee: https://dev.to/nicebills/i-read-321-touch-grass-entries-none-of-them-could-referee-a-game-of-simon-says-so-i-built-one-4if7
2. p0rt, SecondRead: https://dev.to/p0rt/it-caught-partner-pareja-in-an-irs-rule-and-missed-return-devolucion-an-offline-mt-7ge
3. chestly_ace, DuckWalk: https://dev.to/chestly_ace/duckwalk-a-rubber-duck-that-wont-talk-to-you-until-you-go-outside-222j
4. coffeeandcommits, Dabba: https://dev.to/coffeeandcommits/dabba-a-4b-model-reads-a-tiffin-kitchens-whatsapp-group-and-tabpfn-says-how-many-thalis-to-cook-4dke
5. kishore_p_e1fbd59e95a81f6, ScopeGuard (Tinker LoRA vs base table): https://dev.to/kishore_p_e1fbd59e95a81f6/scopeguard-how-i-built-an-nda-safe-local-gemma-3-agent-that-saved-1190-in-freelance-scope-creep-48e0
6. synthreaper, "I tried to fire my LLM API" (LoRA in 85s for $1.03): https://dev.to/synthreaper/i-tried-to-fire-my-llm-api-open-models-passed-one-task-and-failed-the-other-1jde
7. hotragn, Flare: https://dev.to/hotragn/flare-snap-where-you-are-and-it-only-lights-if-enough-friends-quietly-say-yes-1jc8
8. scar3max, Prep Sheet: https://dev.to/scar3max/prep-sheet-a-cafe-prep-planner-3l0n
9. memeshe, Confidant (40-step LoRA, Qwen3.5-4B): https://dev.to/memeshe/confidant-a-1b-gemma-drafts-a-therapists-session-notes-on-her-own-laptop-and-nothing-leaves-the-5fnp
10. kit57, CartaClara (Tinker pipeline, lists Tinker as a prize category): https://dev.to/kit57/hacktoberfest-2026-week-2-cartaclara-m66
11. abbas7120, ColdMailR (sampling only, no tuning): https://dev.to/abbas7120/i-built-a-cold-email-generator-that-never-needs-a-prompt-90
Not counted
- abeera_lodhi, Clear Hour: created two training clients as a probe, trained nothing, and says it doesn't claim Tinker.
- krish007code, Invoice Sorter: "Tinkering" is just the word, not the platform.
ScopeGuard says it tuned Gemma 3 4B on Tinker, but chestly_ace wrote that Gemma isn't available on Tinker. That claim looks doubtful, though it's the judges' call.
The 59-post retry is still running. I'll add anything it finds.
posts without demo or video have usually lower chance to win unless teh project is way too good, some post dont even have any image of their project just github repo and some stats
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re
want=['nicebills','p0rt','chestly_ace','coffeeandcommits','kishore_p_e1fbd59e95a81f6','synthreaper','hotragn','scar3max','memeshe','kit57','abbas7120','anishisbusy']
for f in glob.glob('body/*.json'):
try: x=json.load(open(f)); b=x['body_markdown']; u=x['user']['username']
except: continue
if u not in want or not re.search('tinker',b,re.I): continue
vid=len(re.findall(r'\{%\s*(?:embed|youtube|vimeo|loom)[^%]*(?:youtu|vimeo|loom|\.mp4)[^%]*%\}|youtu\.be|youtube\.com|loom\.com|\.mp4|\.gif',b,re.I))
img=len(re.findall(r'!\[[^\]]*\]\(',b))
live=re.findall(r'https?://[^\s)\]]*(?:onrender|vercel|netlify|fly\.dev|ondigitalocean|github\.io|hf\.space|pages\.dev|railway)[^\s)\]]*',b)
print(f"{u:28} video/gif:{vid} images:{img} cover:{bool(x.get('cover_image'))} live:{len(set(live))} len:{len(b)} reading:{x['reading_time_minutes']}")
E
scar3max video/gif:1 images:0 cover:False live:0 len:6963 reading:5
synthreaper video/gif:0 images:0 cover:True live:0 len:2933 reading:2
coffeeandcommits video/gif:1 images:3 cover:False live:1 len:11842 reading:8
abbas7120 video/gif:0 images:1 cover:False live:1 len:6148 reading:4
p0rt video/gif:0 images:5 cover:True live:2 len:18800 reading:13
kishore_p_e1fbd59e95a81f6 video/gif:0 images:0 cover:False live:0 len:12240 reading:7
memeshe video/gif:3 images:0 cover:True live:0 len:17763 reading:12
hotragn video/gif:2 images:9 cover:True live:13 len:42718 reading:28
anishisbusy video/gif:1 images:1 cover:False live:1 len:11257 reading:8
nicebills video/gif:4 images:1 cover:False live:0 len:16604 reading:11
chestly_ace video/gif:0 images:0 cover:False live:0 len:9136 reading:6
kit57 video/gif:0 images:0 cover:False live:0 len:2209 reading:2
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
Agreed. Here is how the 12 Tinker posts compare on demo material. The counts come from searching each post's text, so treat them as rough.
| Author | Video or GIF | Images | Live demo link |
|---|---|---|---|
| hotragn (Flare) | yes (2) | 9 | yes |
| p0rt (SecondRead) | none found | 5 | yes (2) |
| nicebills (Simon Says) | yes (4) | 1 | none found |
| memeshe (Confidant) | yes (3) | 0 | none found |
| coffeeandcommits (Dabba) | yes | 3 | yes |
| you (ReplyDesk) | yes (YouTube) | 1 | yes |
| abbas7120 (ColdMailR) | none found | 1 | yes |
| scar3max (Prep Sheet) | yes | 0 | none found |
| synthreaper | none | 0 | none |
| kishore (ScopeGuard) | none | 0 | none |
| chestly_ace (DuckWalk) | none | 0 | none |
| kit57 (CartaClara) | none | 0 | none |
Cheap upgrades for your post: add a cover image, add 3–4 screenshots (the inbox, the /ab blind test, the eval chart), and embed the demo video near the top. Those would help whatever else we do.
The 59-post retry is still running. Want to plan the model fix (v4) next, or the post visuals first?
i would like you to judge these people submission and what were they able to achieve
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re
def get(u):
for f in glob.glob('body/*.json'):
try: x=json.load(open(f));
except: continue
if x.get('user',{}).get('username')==u and re.search('tinker',x.get('body_markdown',''),re.I): return x['body_markdown']
b=get('synthreaper'); print('## synthreaper\n',b[:3000])
b=get('kit57'); print('\n## kit57\n',b[:2300])
b=get('kishore_p_e1fbd59e95a81f6'); i=b.find('Tinker Benchmark Results'); print('\n## kishore\n',b[i:i+1800])
b=get('memeshe'); i=b.find('Tinker is wired'); print('\n## memeshe\n',b[i-200:i+1500])
b=get('chestly_ace'); i=b.find('## Results'); print('\n## chestly\n',b[i:i+1800])
b=get('coffeeandcommits'); i=b.find('### 2'); print('\n## dabba\n',b[i:i+3500])
E
## synthreaper
---
title: I tried to fire my LLM API. Open models passed one task and failed the other.
published: true
tags: devchallenge, hf26challenge, opensource, ai
---
*This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle](https://dev.to/challenges/hacktoberfest-week2-2026-10-12)*
## What I Built
**Open-Switch** answers one question: *can an open model replace your closed LLM API on YOUR data?*
It runs your workflow through open-weight models, scores them with bootstrap 95% CIs, and gives a verdict: **Safe Switch** or **No Safe Switch**. It also tells you if a LoRA fine-tune pays for itself.
**Who pays:** B2B SaaS teams cutting LLM bills, and consultants selling migration audits. Hypothesis: $49/mo self-serve, $199/mo agency.
## Demo
Runs fully local (no hosted link, by design: your data never leaves your machine):
```bash
git clone https://github.com/SynthReaper/ghw-hacktoberfest26.git
cd ghw-hacktoberfest26/switchable && pip install -e .
openswitch run --models gemma-2b-local,gemma-9b-local --task specs/task.yaml
```
## Code
{% embed https://github.com/SynthReaper/ghw-hacktoberfest26 %}
The project lives in the [`switchable/`](https://github.com/SynthReaper/ghw-hacktoberfest26/tree/main/switchable) folder. Key files:
- [`provenance.py`](https://github.com/SynthReaper/ghw-hacktoberfest26/blob/main/switchable/openswitch/datasets/provenance.py): default-deny guard against training on closed-API outputs
- [`evaluator.py`](https://github.com/SynthReaper/ghw-hacktoberfest26/blob/main/switchable/openswitch/judge/evaluator.py): AB/BA position-bias defense
- [`stats.py`](https://github.com/SynthReaper/ghw-hacktoberfest26/blob/main/switchable/openswitch/stats.py): 10k-resample bootstrap CIs
## How I Built It
- **Models:** Gemma 2 (2B/9B), Llama 3.1/3.2, Qwen 2.5 7B via Ollama
- **Judge:** every pair is scored in AB *and* BA order; disagreements are flagged (90% agreement with human labels, κ = 0.85)
- **Provenance guard:** blocks fine-tuning on closed-API outputs, then regenerates references with an open teacher
- **109 tests**, 10k-resample bootstrap CIs on every metric
| Task | Best open model | Score [95% CI] | Verdict |
|---|---|---|---|
| Support triage | Qwen 2.5 7B + LoRA | 90.0% [80, 98] | ✅ Safe switch, 85% cheaper |
| Banking intent | Gemma 2 2B | 41.2% [27.5, 54.9] | ❌ Not safe |
The tool says "no" when the answer is no. That's the point.
## Why Does Open Innovation Matter?
Closed APIs rent you a fine-tune. Open models give you a **LoRA adapter you own forever**. A $1.03 fine-tune broke even at ~10k requests/month, while 3-shot prompting quadrupled per-request cost.
## My Agent Session
Built with Antigravity under a strict `AGENTS.md`: pre-flight gates, TDD, and a no-fabricated-metrics rule.
## Prize Categories
- **Best Use of Gemma:** 2B runs locally; 9B is the open teacher
- **Best Use of Tinker:** LoRA training in 85s for $1.03
## kit57
## What I Built
CartaClara helps restaurants turn a Spanish-language menu into a QR menu in English, French, and German. It suggests translations and tags each dish with the 14 EU allergens, then lets the restaurant owner review and confirm every dish before publishing.
It’s designed for independent restaurants that want to serve international guests without translating and maintaining multiple menus by hand. A restaurant could pay for the time saved and an easier-to-update multilingual menu. Allergen information still needs to be checked by the restaurant; the suggestions aren’t a substitute for that review.
## Demo
There isn’t a public demo deployed yet. You can run CartaClara locally by following the setup instructions in the [repository README](https://github.com/kit57/side_hustle_hacktoberfest2026#run-it-locally). With the default mock backend, you can try the workflow without configuring a model.
## Code
https://github.com/kit57/side_hustle_hacktoberfest2026
## How I Built It
CartaClara uses an open-weight language model to suggest dish translations and allergen tags. The project includes a Tinker fine-tuning pipeline for Spanish dishes, plus mock and OpenAI-compatible backends—including a local open-weight model served through Ollama. The prompt and output format are shared between inference and training, and the evaluation script compares the base model with the fine-tuned checkpoint on held-out dishes.
The app also supports an optional restaurant glossary: when an owner corrects and confirms a suggestion, that correction can be saved and used for that restaurant’s future menus.
## Why Does Open Innovation Matter?
Restaurants need to trust and correct the menu suggestions, especially when allergen information is involved. Building on open-weight models gives the project room to experiment with Spanish food terminology, fine-tune for the task, and offer different ways to run a model—including a local OpenAI-compatible server—instead of tying the product to one closed model API.
Open development also makes the training examples, evaluation approach, and application code available for others to inspect and improve.
## Prize Categories
Tinker, BACKBOARD
## kishore
Tinker Benchmark Results (`node scripts/evaluate-tinker.mjs`)
| Evaluation Metric | Base Gemma 3 4B | Tinker Fine-Tuned LoRA | Delta / Gain | Real-World Impact |
| --- | --- | --- | --- | --- |
| **Polite Scope Trap Detection** | 62.5% | **98.1%** | **+35.6%** | Catches sneaky requests disguised as "quick thoughts" |
| **JSON Schema Adherence** | 84.2% | **100.0%** | **+15.8%** | Zero markdown wrapper leaks; 100% UI reliability |
| **Contract Clause Citation** | 58.0% | **95.4%** | **+37.4%** | Cites exact SOW section without hallucinating clauses |
| **Diplomatic Tone Rating** | 74.1% | **96.8%** | **+22.7%** | Firm yet warm pushback that preserves client trust |
| **Overall F1 Classification** | 71.4% | **96.2%** | **+24.8%** | Production-grade contract auditing accuracy |
| **Average Inference Latency** | 1,120 ms | **780 ms** | **-30.3%** | Concise structured output cuts generation time |
---
## Inside the Code: Instrumenting Mastra + MongoDB + Sentry
One of my favorite engineering parts of ScopeGuard is how cleanly **Mastra tools** and **Sentry AI Agent Tracing** compose together. Here is how `sowRetrieverTool` fetches contract boundaries from MongoDB Atlas while recording a custom `gen_ai.execute_tool` telemetry span:
```typescript
// src/mastra/tools/sow-retriever.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import { getProjectSow } from "@/lib/mongodb";
export const sowRetrieverTool = createTool({
id: "retrieve-sow-clauses",
description: "Retrieves signed Statement of Work (SOW) clauses and hourly rates from MongoDB Atlas.",
inputSchema: z.object({
projectId: z.string(),
keywords: z.string(),
}),
execute: async ({ context }) => {
return await Sentry.startSpan(
## memeshe
844, output_tokens: 347}` and `{transcript_chars: 600}`. Metadata only; no clinical text, verifiable without an account. It stays unclaimed because metadata tracing is not what the category asks for. Tinker is wired too: a 40-step LoRA on `Qwen/Qwen3.5-4B` over the note-structuring task, trained on the deterministic floor's outputs and scored on six held-out recaps with the same scorer the committed eval uses (`eval/results/tinker-lora-20261014-002815.json`). The loss fell from 0.082 to 0.0015 per supervised token — and the tuned arm still did not dominate the floor: two runs of the same config landed on either side of it on section placement (2.17 and 3.17 of 7 against the floor's 2.50), while producing unsupported claims (0.33 and 0.5 per note) that the floor never produces at all. A first run that loses is the honest state of the art here; filing it as a win would be the lie this whole project exists to avoid.
Confidant is a small thing: one therapist, one laptop, one recap, and one note that can prove where every sentence came from. That it can exist at all is a property of open models. That it refuses to write what she did not say is the property I care about most.
## chestly
## Results
The fine-tuning went from 68% to 98% on a frozen 50-prompt rubric, matching a much larger cloud model on a 4B budget. Full caveats in [eval/results.md](https://github.com/chestlyace/DuckWalk/blob/main/eval/results.md).
For reliability: a 2-hour run, 23 walk sessions back to back across 5 bug scenarios and 3 scripted conversations, zero failures and no manual intervention. My dev machine actually rebooted partway through the night, so I confirmed this straight from Temporal's own workflow history rather than a log file that didn't survive.
All four failure drills pass. Ollama going down makes the nudge and brief fall back to canned text instead of killing the walk. The worker restarting mid-conversation resumes without losing anything. The phone going offline just syncs later. A malformed detector input fails with a clear error instead of hanging.
What isn't done: the stuck detector has no real held-out precision or recall yet. Hand-labeling real work windows didn't get finished before the deadline, so I'm saying that plainly instead of papering over it with a synthetic number. `ml/train_detector.py` is ready to produce the real learning curve the moment there's labeled data to feed it.
## Prize Categories
**Best Use of Gemma.** Writes the breadcrumb, the nudge, the walk-mode pick, and the return brief, and was also the teacher model behind the fine-tuning data and the eval judge.
**Best Use of TabPFN.** Classifies each 5-minute window as stuck or flow from six local signals with no feature engineering or tuning. I'll say the caveat up front: real held-out metrics aren't collected yet, for the reason above. The classifier, its Sentry instrumentation, and its failure handling are all real and tested regardless.
**Best Use of Tinker.** Took a 4B model
## dabba
### 2. The memory: Backboard
"1 dabba" from Harsh means a paneer thali. No model can know that; the kitchen can. When the owner corrects a parsed order once, Dabba writes one Backboard memory:
```http
POST /assistants/{id}/memories {"content": "Harsh Khan: usual dish paneer_thali, prefs no_onion."}
```
Before parsing a message, Dabba searches those memories with the message, keeps only the ones whose subject is the sender, and prepends them to the system prompt as a *Known rules from the kitchen* block. The model was trained with that block present on 18% of examples, so it obeys it. On the 35 test messages where a bare "dabba" came from a non-veg-thali regular, the zero-shot 120B picked the right dish 69% of the time with the same rule in its prompt; the fine-tune picked it 100%.
Memory writes and reads are plain API calls, no LLM in the loop, which is what you want for a fact as boring as "Harsh takes paneer".
### 3. The forecast: TabPFN inside a small prediction agent
At 9 pm the kitchen knows three things: what it sold on each day of the last five months, what today's weekday and date are, and how many dabbas are *already* booked for tomorrow. That last one is the strong signal, and it is a known-future covariate, which is why I used TabPFN as a tabular regressor with hand-built features rather than a pure time-series model. For each dish and meal:
```plaintext
dow, day of month, holiday, Navratri, advance_orders (booked by 9 pm),
lag1, lag7, lag14, 7-day mean, 28-day mean, 7-day mean of advance orders, advance share
```
Rain is in the history and deliberately *not* a feature: nobody knows tomorrow's rain at 9 pm.
The model is `TabPFNRegressor` from Prior Labs, fitted on everything before the test day and asked for the 10/50/90 quantiles. I walked it forward over the last 28 days against the rules of thumb an owner actually uses, plus gradient boosting for honesty:
| Predictor (per dish, per meal) | MAE in dabbas | over-cooked / day | under-cooked / day | exactly right |
|---|---|---|---|---|
| Same as yesterday | 2.56 | 18.1 | 17.7 | 24% |
| 7-day average | 2.30 | 15.7 | 16.5 | 26% |
| Last week, same weekday | 1.79 | 12.6 | 12.5 | 32% |
| Advance orders ÷ usual advance share (what an owner does) | 1.55 | 12.4 | 9.3 | 36% |
| Gradient boosting on the same features | 1.07 | 7.3 | 7.7 | 39% |
| **TabPFN** | **0.90** | **3.9** | **8.7** | **51%** |
28 test days, 14 dish-meal series, 392 predictions, walk-forward with the model refitted weekly. "Over/under-cooked" are summed across all dishes for a day; "exactly right" is the share of dish-day predictions that hit the integer.

The number that matters is the over- and under-cooked columns, because they are the money. The owner's own rule mis-cooks about 22 dabbas a day across the menu; TabPFN mis-cooks about 13, and most of its misses are on the under side, which is why the app's "cook" number leans towards the top of the band when the extra is cheap. Gradient boosting on identical features gets close on MAE, but TabPFN over-cooks half as much and needed no tuning at all.
One honest note on the band: it was asked for 10–90% and covered 97% of outcomes, so it is wider than it claims. For a kitchen that is the right failure mode, but it is a failure mode.
### 4. Render
One `render.yaml`, one Python web service,
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
My ranking for Best Use of Tinker, from the post text only. I haven't opened any repos, so I can't confirm the numbers.
Top tier
1. nicebills, Simon Says referee. Strongest overall.
- It tuned Qwen3.5-4B to judge poses in crop photos. Result: 93.2% vs 87.4% untuned vs 63.1% for the local baseline.
- The test set is 222 hand-checked crops that never touched training.
- It admits the gain is not significant (p=0.08) and that it chose categories after seeing test errors.
- It reports cost (about $2), speed, and a way to export the weights.
- Weak spots: no live demo, and the weights can't be shared because of image licences.
2. p0rt, SecondRead. Most rigorous.
- 4 runs, $6.46 total. It ties Gemma 31B on WMT (AUROC 0.815 vs 0.807) and beats its own untuned base.
- It reports where it loses: Gemma is better on real documents, and the default flag line catches only a fifth of errors.
- It runs offline as a 36 MB adapter. It also targets DigitalOcean and Sentry.
- Weak spot: a narrow audience (translators).
3. coffeeandcommits, Dabba.
- A tuned 4B model picked the right dish 100% of the time, against 69% for a 120B model given the same rule.
- It runs on Tinker's sampling API with Backboard memory and TabPFN forecasts, and lists several prize categories.
- Weak spots: the training data is simulated, and the test messages come from the same generator, so real-world accuracy is unproven.
Middle
4. chestly_ace, DuckWalk. Rubric score went 68% → 98%, equal to a cloud Gemma at 80%. It is honest about the "not better" part. The judge is Gemma, so it is not independent. The post has no visuals, and the stuck detector is unmeasured.
5. ReplyDesk (you). Real data, a real first user, honest write-up, a live app and a video. But the tuned model lost both tests. The result looks like memeshe's.
6. hotragn, Flare. A very polished project, but Tinker is a side part. The LoRA read 38/40 phrasings, wasn't shipped, and only taught the rules.
7. kishore, ScopeGuard. Huge claims: F1 71% → 96%, 100% JSON adherence, and 30% lower latency. The numbers look too clean, and the post doesn't show where the test data came from. It says it tuned Gemma 3 4B on Tinker, which chestly_ace says isn't offered. No visuals. Treat it as unverified.
Bottom
8. synthreaper. The tool is interesting, but Tinker gets one line ("85s, $1.03"). It is a short post and has no demo.
9. memeshe, Confidant. The LoRA lost, and it reports that honestly. It evaluated only 6 recaps. Tinker is a side note.
10. scar3max, Prep Sheet. A one-step smoke test with no measured gain. It says so itself.
11. abbas7120, ColdMailR. Prompting through the sampling API with no tuning. The "moat" is only a plan.
12. kit57, CartaClara. The pipeline exists in the repo, but the post shows no result, demo or numbers.
What this means for you
- The $200 prize probably goes to nicebills, p0rt or Dabba. All three show a clear win for the tuned model, backed by test data.
- You are mid-pack. A v4 that beats the prompted baseline on one measured thing would move you into the top 4. A better post would help, but the model result matters more.
- Your plus points are real users and real data. Most rivals use synthetic data.
The 59-post retry is still running. Want to move on to the v4 plan?
also are we only targetting tinker this time?
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
cat ../../tasks/bnrwy40ln.output | tail -2
python3 -I - <<'E'
import json,glob,re
for name,pat in [('Tiger Data',r'tiger ?data|tigerdata|timescale|pg_textsearch|tiger cloud'),('Backboard',r'backboard')]:
r=[]
for f in glob.glob('body/*.json'):
try: x=json.load(open(f)); b=x['body_markdown']
except: continue
n=len(re.findall(pat,b,re.I))
if n>=2: r.append((n,x['user']['username'],x['url'],len(b)))
r.sort(reverse=True); print('\n##',name,len(r))
for t in r[:15]: print(t[0],t[1],t[2][:110])
E
59 bad
59 bad
## Tiger Data 12
11 priyanshu_jha_eee132b17e5 https://dev.to/priyanshu_jha_eee132b17e5/shadeloop-an-open-model-field-journal-that-gets-you-outside-for-20-mi
9 satanic47 https://dev.to/satanic47/receiptdesk-ai-privacy-first-invoice-deduction-auditor-live-at-shivysidinfotech-afm
7 robinson27225 https://dev.to/robinson27225/recipe-roots-a-shoebox-of-recipe-cards-a-wikitree-profile-and-a-cookbook-your-fam
7 dev-aarish https://dev.to/dev-aarish/i-trained-an-open-tabular-ai-to-catch-the-72-hour-fall-foliage-peak-so-you-can-keep-
6 nfedotenko https://dev.to/nfedotenko/i-built-a-localization-pass-that-never-lets-an-unreleased-game-script-leave-the-lapt
6 mendisofficial https://dev.to/mendisofficial/stallsheet-a-sunday-price-desk-that-does-not-let-the-model-invent-the-price-50ne
5 sithijaseneviratne https://dev.to/sithijaseneviratne/the-most-expensive-task-manager-for-a-side-hustle-is-the-one-in-your-head-31
5 anishisbusy https://dev.to/anishisbusy/replydesk-a-small-tuned-model-for-google-review-replies-and-the-test-it-lost-4ab6
5 abhishek_44d97e81b5a38faa https://dev.to/abhishek_44d97e81b5a38faa/namelock-159f
3 abubakar_ahmed https://dev.to/abubakar_ahmed/i-built-pakistans-missing-paddling-app-it-predicts-river-flows-speaks-urdu-and-r
3 aarishmansur https://dev.to/aarishmansur/i-saw-my-local-shopkeeper-writing-things-1mg4
2 sachin-alt https://dev.to/sachin-alt/outtascope-the-client-scope-creep-unpaid-revision-shield-for-freelancers-2fpc
## Backboard 15
15 reggiebro https://dev.to/reggiebro/i-built-a-faceless-video-factory-that-remembers-which-content-pays-46c1
15 priyanshu_jha_eee132b17e5 https://dev.to/priyanshu_jha_eee132b17e5/shadeloop-an-open-model-field-journal-that-gets-you-outside-for-20-mi
11 devwishan https://dev.to/devwishan/freelanceforge-ai-turn-raw-client-chaos-into-billable-deliverables-26d2
8 pr7426 https://dev.to/pr7426/hermessec-turning-open-weight-gemma-ast-triage-into-a-149-freelance-security-audit-side-
8 icyfire8 https://dev.to/icyfire8/connect-nature-281p
6 rohitisavailable https://dev.to/rohitisavailable/passthrough-catch-shoot-expenses-before-they-disappear-2jeh
6 danish_irfan https://dev.to/danish_irfan/i-built-an-ai-app-that-actually-wants-you-to-put-your-phone-away-1c6o
5 yatharth_srivastava_a0880 https://dev.to/yatharth_srivastava_a0880/hover-around-move-around-and-relive-boredom-again--4g1m
5 im45145v https://dev.to/im45145v/boutgooutside-bingo-less-scrolling-more-strolling-2bb4
4 richmondandoh https://dev.to/richmondandoh/snap-a-shoe-sell-the-shoe-a-sales-kit-generator-on-open-weight-ai-448a
4 pradeepa_17 https://dev.to/pradeepa_17/wildmon-find-more-outside-493a
4 anishisbusy https://dev.to/anishisbusy/replydesk-a-small-tuned-model-for-google-review-replies-and-the-test-it-lost-4ab6
3 owaismohammed79 https://dev.to/owaismohammed79/echoes-a-screen-free-voice-first-ai-walking-guide-3c3n
3 kim257223 https://dev.to/kim257223/global-travel-buddy-your-complete-ai-assistant-for-traveling-anywhere-84n
3 coffeeandcommits https://dev.to/coffeeandcommits/dabba-a-4b-model-reads-a-tiffin-kitchens-whatsapp-group-and-tabpfn-says-how-ma
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
No. The plan has three tracks: Tinker (main, $200), Tiger Data ($100) and Backboard ($100). ReplyDesk already uses all three: Tiger for search, Backboard for shop-facts memory, Tinker for the tuned model. I dropped .tech, DigitalOcean and Arduino earlier.
I counted how many week-2 posts use each partner, from the 741 posts I read. Posts with only a passing mention are excluded.
The smaller fields make Tiger Data and Backboard better odds than Tinker. Your use of both is real and tied to the product (BM25 plus vector search, and shop memory). Tinker is the long shot unless v4 beats the base model.
So the plan can be: keep all three, put the model work into v4 for Tinker, and make the Tiger and Backboard sections of the post more concrete. For example, show a screenshot of the hybrid search finding similar past reviews, and say which memory facts the guardrail uses.
The 59-post retry hasn't recovered any of them after several rounds. Those posts are probably unpublished or blocked, so I'll stop retrying unless you want to try another way.
Want me to review how Tiger and Backboard are used in ReplyDesk and what could be shown better, or go straight to the v4 plan?
[Image #1] i still havev some credits left for this month, we get 10$ every month or is it one time use? if yes that means we wont be able to participate with tinker next 2 weeks right
[Image: source: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/images/1.png]
$ cd /home/anish-kumar/Code/hacktoberfest/week2 && grep -n -i -B1 -A3 -E "credit|\\\$10|redeem|expire|month" CONTEST.md | head -60
59-
60:All prizes are **cash in USD**, not platform credits.
61-
62-| Prize | Count | Reward |
63-|---|---|---|
--
65-| Featured category winner | 6 | $200 USD + exclusive DEV badge |
66:| Partner category winner | 11 | $100 USD + exclusive DEV badge |
67-| Every valid entry | all | Completion badge on DEV profile |
68-
69-**Each participant can win at most one prize in this contest.** Entering more categories only gives more chances to win one prize. The prizes don't add up.
70-
71:Partner credits (Tinker, Render, Backboard, free .tech domain) are separate free perks for building, claimed at https://hacktoberfest.com/my/promos. They are not prizes.
72-
73-### Claiming a cash prize
74-
--
100-
101:### Partner categories ($100)
102-
103-| Category | What counts | Week 1 entries | Weekend | Usable for us? |
104-|---|---|---|---|---|
--
126-- **Teams:** up to 4 people. List teammates' DEV usernames in the post. Publish once per team.
127:- **Open source code:** borrowing and improving is fine, but changes must be big enough, and any non-trivial prior work must be credited. Plagiarism means disqualification or a DEV ban.
128-- **English only** for prizes. Non-English posts only get the completion badge.
129-- **AI tools are allowed.**
130-- **Age 18+.**
--
173-
174:<!-- Team Submissions: Please pick one member to publish the submission and credit teammates by listing their DEV usernames directly in the body of the post. -->
175-
176-<!-- Thanks for participating! -->
177-```
--
207-
208:1. **Tiger Data** ($100, 17 week 1 entries): pgvector + hybrid search at the core of the app.
209:2. **Tinker** ($200, 27 entries): main track. $10 credit redeemed, no card. Fine-tune a small open model for the side hustle's task and show it beats the base model.
210:3. **Backboard** ($100, 23): $5 credit redeemed, no card.
211:4. **Mastra** ($100, 30) if the app has an agent.
212-
213-Dropped: .tech Domain (needs a card), DigitalOcean (needs a card), Arduino (needs an UNO Q; we only have an UNO R3 clone).
214-
--
224-|---|---|---|---|---|
225:| .tech domain | Free first year | **Yes** (found Oct 13) | Redeem the code at get.tech | **Dropped:** needs card details. Code expires Dec 30, 2026 |
226:| Backboard | $5 credit | **No** (stated) | Sign up, enter the code on the Billing page | **Redeemed Oct 13.** The free plan also has its own $5 credit |
227:| Render | $50 credit | Unclear (support says credits are used "instead of the card on file") | Billing → Credit Balance | **Code HTHON-DB322A fails with "invalid code".** Support ticket open since Oct 8, only the AI bot has replied. Claim by Nov 15, 2026 |
228:| Tinker | $10 credit | **No** | Use the promo code | **Account made, redeemed Oct 13.** Enough for a small fine-tune (see below) |
229-
230-Services with no promo:
231-
--
234-| Mastra | Yes, open-source npm framework | No account needed | Used in week 1 |
235:| Temporal | Yes when self-hosted (`temporal server start-dev`) | No for self-hosted. Temporal Cloud has a $1,000 sign-up credit; card need unclear | A live demo needs the server and a worker running all the time somewhere |
236-| Tiger Data | Free plan | No | Account already exists |
237-
238:**Tinker cost check:** cheapest training rates per million tokens are GPT-OSS-20B $0.396, Qwen3-8B $0.44 (sampling $0.45–0.60). $10 covers roughly 15–20M training tokens, far more than a small LoRA fine-tune on a few thousand examples needs. Leave room for baseline vs fine-tuned eval runs.
239-
240----
The screenshot shows spend, not your balance, so I can't say exactly how much is left.
CONTEST.md) say the $10 came from a Hacktoberfest promo code, redeemed Oct 13. That looks like a one-time code, not $10 every month. I haven't seen Tinker's own terms, so check the Billing page for the real balance and any expiry.Next two weeks: I don't know which partners weeks 3 and 4 have. Week 3 starts Oct 19 and week 4 on Oct 26, and the announcement may not list Tinker. If Tinker is a partner again, $5 still covers one small fine-tune. Your v3 run cost about $4, and Dabba and SecondRead each spent $1–2 per run.
To save credit:
- Checkpoint storage costs $0.10 per GB-month. Delete old checkpoints you won't use (keep the v3 one the app uses). Storage is only $0.18 now, but it adds up.
- Sampling the judge model is the costly part of evals, so use a smaller judge or fewer samples.
- For v4, reuse the v3 data and change only the part we target. One run at about $2 leaves room for one eval.
Do you want me to check your Tinker Billing page for the exact balance, or first plan how to spend the remaining ~$5 on v4?
[Image #2] i mean , its fine if we are not able to participate via tinekr next week, we will choose some other track, anyways what is more important is making sure we win this one using tinker, im ready to spend all the credits for this submission but we have to win, also see what other things we can improve in the project other than the tuning the model
[Image: source: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/images/2.png]
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && ls ml ml/* docs scripts db app lib components 2>/dev/null | head -120; cat AGENTS.md | head -20; git log --oneline 2>/dev/null | head -20
ml/pyproject.toml
ml/uv.lock
app:
ab
actions.ts
better
globals.css
layout.tsx
page.tsx
setup
components:
ab-test.tsx
add-review-dialog.tsx
inbox.tsx
providers.tsx
setup-form.tsx
top-bar.tsx
db:
schema.sql
docs:
shots
lib:
ab-sample.ts
ab.ts
backboard.ts
csv.ts
data-db.ts
data-mock.ts
data.ts
db
embed-worker.ts
facts.ts
format.ts
guardrail.ts
model.ts
prompt.ts
types.ts
use-embedder.ts
ml:
data
pyproject.toml
replydesk_ml
runs
scripts
uv.lock
ml/data:
eval
eval_stale
examples_pool.parquet
pairs.parquet
raw
stats.json
test.jsonl
train_chat.jsonl
train.jsonl
v1
v2
val_chat.jsonl
val.jsonl
ml/replydesk_ml:
__init__.py
prompt.py
__pycache__
ml/runs:
eval_gen.log
eval_judge.log
load_examples.log
smoke
v1
v1.log
v2
v2.log
v3
v3.log
ml/scripts:
build_dataset.py
eval.py
extract.py
load_examples.py
__pycache__
to_chat.py
train.py
scripts:
seed.ts
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
9765bc9 Fit the blind-test progress bar and nav on phones, add inbox heading for screen readers
a4ad74d Match similar replies on the same side of the rating, drop repeated reviews
15e33ab Word the judge finding more carefully
10157a5 Use v3 model, show full eval and blind-test votes on /better, rewrite README
6bf3c9f Filter training data for tone and detail, move eval to Tinker
885f83a ReplyDesk: review reply desk for small shops
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && sed -n 1,200p README.md
# ReplyDesk
Short, warm replies to Google reviews, written in the shop owner's voice by a small open model (Qwen3.5-4B) fine-tuned on real owner replies. Built for the Hacktoberfest 2026 DEV challenge ("Side Hustle").
Live demo: **https://replydesk-shop.vercel.app**

Small shops get reviews every week and most never answer them, because writing a good reply takes time and the replies that chat assistants write sound like a bank. ReplyDesk gives the owner an inbox of reviews, a draft reply for each one, the shop facts that matter for that review, and how they (and owners like them) answered similar reviews before. The owner edits, copies and posts it. Nothing is posted for them.
## What's inside
```mermaid
flowchart LR
B[Browser] -- review text --> E[bge-small in a Web Worker]
B -- server action --> S[Next.js on Vercel]
S -- draft --> T[Tinker: fine-tuned Qwen3.5-4B]
S -- facts for this review --> M[Backboard memory]
S -- promise check --> G[Groq: gpt-oss-20b]
S -- reviews, replies, eval, votes --> P[(Tiger Cloud Postgres)]
E -- query vector --> S
P -- BM25 + DiskANN, fused --> S
```
| Part | Used for |
|---|---|
| **Tinker** | LoRA fine-tune of Qwen3.5-4B, and serving the fine-tuned model through Tinker's OpenAI-compatible endpoint. The eval also runs on Tinker. |
| **Tiger Cloud** | Postgres 18 with `pg_textsearch` (BM25) and `vectorscale` (DiskANN). Holds the shop, reviews, sent replies, 11.5k example replies, eval results and blind-test votes. |
| **Backboard** | One memory assistant per shop. Shop facts ("Free re-check within 7 days") are saved there, and memory search picks the ones that matter for each review. |
| Groq | Checks each draft for promises the shop facts do not back. |
| Next.js 16, React 19, Tailwind 4, drizzle-orm, Phosphor icons, bun | The app. |
## The model
### Data
[Google Local Reviews](https://mcauleylab.ucsd.edu/public_datasets/gdrive/googlelocal/) from the McAuley lab at UCSD. I used four states (Wyoming, Vermont, Montana, Idaho): 590,054 review and owner-reply pairs. The raw data is not in this repo.
`ml/scripts/build_dataset.py` keeps a pair only if:
- the review and reply are in English, not machine-translated, and the reply came within 60 days
- the reply has no phone numbers, emails or links
- the business is not a chain (same name at more than 3 places) and is one of six shop types: auto repair, salon, dentist, cafe/restaurant, home services, retail
- the reply is not a template (the same text used more than twice by one business, or the same opening shared by more than 8 businesses)
- the reply shares at least one content word with the review
That leaves 50,954 pairs. Sign-offs ("Thanks, Bob", "-Ryan, Owner") are stripped because the app adds the owner's name itself. Businesses are split into train, validation and test, so no test business is ever seen in training.
The training set is filtered harder. Replies must be 20+ words, overlap the review by at least two content words, not argue with the customer or call the review false, and for 1 to 3 star reviews, acknowledge the problem in the first lines. That gives 6,000 training pairs (2,262 of them low-star).
### Training
`ml/scripts/train.py` uses `tinker-cookbook` supervised learning: LoRA rank 32, learning rate from the cookbook's helper (about 4.9e-4), batch 64, one epoch, loss on the reply only, thinking turned off in the chat template. The prompt in `ml/replydesk_ml/prompt.py` is the same one the app sends (`lib/prompt.ts`).
It took three tries:
| Run | Change | What went wrong |
|---|---|---|
| v1 | All clean pairs, 2 epochs | Learned real owners' bad habits too: "Cameron, you are obviously confused. We did not see you in the store." |
| v2 | Dropped defensive and stock replies | Polite, but bland. Many replies could have gone under any review. |
| v3 | Kept only replies that are long enough and talk about what the reviewer said | The one the app uses. Test loss 2.125. |
v3 trained on 1.28M tokens for about $0.95. All three runs together came to about $4 of Tinker credit.
### Eval
`ml/scripts/eval.py` writes a reply to 300 held-out reviews (half of them 1 to 3 stars) with four systems, then a judge from a different model family (gpt-oss-120b on Tinker) sees the four replies in random order with no labels. It marks each one and ranks them.
| | Qwen3.5-4B prompted | **ReplyDesk (4B fine-tuned)** | Qwen3.8-27B prompted | Real owner |
|---|---|---|---|---|
| Median words | 60.5 | **34** | 56 | 34 |
| Stock phrases ("we value your feedback") | 19.3% | **2.0%** | 9.7% | 7.3% |
| Specific promises (refund, call-back, discount) | 18.0% | **11.3%** | 20.3% | 12.7% |
| Mentions a detail from the review | 98.3% | **56.3%** | 98.3% | 55.3% |
| Reads like a template (judge) | 22.7% | **77.0%** | 17.0% | 63.0% |
| Judge's first pick | 40.3% | **5.0%** | 44.0% | 10.7% |
| Cost per reply | $0.00013 | **$0.00010** | not public | |
| Median latency | 0.95 s | **1.0 s** | 1.16 s | |
What this says, plainly:
- Fine-tuning made the model write like real owners. Length, stock phrases and made-up promises all moved from where the prompted model sits to where real owners sit, and stock phrases dropped below even real owners.
- The judge does not like that. It prefers the prompted models, whose replies are longer and nearly always repeat a detail back. It ranks real owners' replies near the bottom too (first pick 10.7%). So the judge rewards polish, and polish is not the same as what an owner would post.
- The fine-tuned model is a bit cheaper per reply because it writes less, and it costs nothing like a closed model.
Whether owners prefer it is a question for owners, so the app has a blind test (`/ab`): two replies to the same review, one from each 4B model, sides shuffled, and you pick the one you would send. Votes go to Tiger and show up on `/better`.
To run it again: `uv run python scripts/eval.py gen`, then `judge`, then `report` (from `ml/`). Generations and judgements are cached.
## Running the app
Needs [bun](https://bun.sh) 1.4+ and Node 22+.
```bash
bun install
cp .env.example .env.local # optional, the app runs without it
bun dev # http://localhost:3000
bun run build && bun run lint
```
With no env vars the app runs offline: sample reviews, an in-memory store, and a mock reply instead of the model. Each service switches on by itself when its variable is set, and the app falls back when a call fails.
| Variable | What it does |
|---|---|
| `TINKER_MODEL_PATH` | `tinker://...` path of the fine-tuned model. Unset means mock replies. |
| `TINKER_API_KEY` | Tinker key. |
| `TINKER_BASE_URL` | Defaults to Tinker's OpenAI-compatible endpoint. |
| `DATABASE_URL` | Postgres (Tiger Cloud). Unset means the in-memory store. Apply `db/schema.sql` first, then `bun --env-file=.env.local scripts/seed.ts` for the demo shop. |
| `BACKBOARD_API_KEY` | Backboard memory for shop facts. Unset means every saved fact is used. |
| `GROQ_API_KEY` | Groq, for the promise check. Unset means a simple keyword check. |
The training side lives in `ml/` and uses [uv](https://docs.astral.sh/uv/): `extract.py` and `build_dataset.py` (data), `to_chat.py` and `train.py` (Tinker), `load_examples.py` (example replies into Tiger), `eval.py`.
## How a draft is made
1. **Setup.** The owner picks a shop type (one of the six the model was trained on), adds shop facts and, if they like, past replies. Facts are also saved to Backboard memory (one assistant per shop, only new facts are added and removed ones deleted). Past replies go into `reply_examples`.
2. **Draft.** The fine-tuned model writes the reply with exactly the training prompt. Thinking is off (`reasoning_effort: false`). The app adds the sign-off.
3. **Facts for this review.** Backboard memory search returns the facts that match the review. If Backboard fails, all saved facts are used.
4. **Similar replies.** Shown beside the draft, never sent to the model. One SQL statement on Tiger: BM25 (`pg_textsearch`) and DiskANN (`vectorscale`) each give their top 20 for the shop's type, and reciprocal rank fusion (k = 60) merges them. The owner's own past replies come first. The review is embedded in the browser (`@huggingface/transformers`, `Xenova/bge-small-en-v1.5`, in a Web Worker), so there is no embedding server. Until the model has loaded, the search uses BM25 only.
5. **Promise check.** Groq (`openai/gpt-oss-20b`) lists every promise in the reply and marks whether the facts back it. Anything not backed turns the stamp to "Check". If Groq fails, a keyword check runs instead.
Where things live: `lib/prompt.ts`, `lib/model.ts` (draft), `lib/backboard.ts` and `lib/facts.ts` (memory), `lib/guardrail.ts` (promise check), `lib/embed-worker.ts` and `lib/use-embedder.ts` (browser embeddings), `lib/data.ts` (one switch between `data-mock.ts` and `data-db.ts`), `db/schema.sql` (tables), `app/actions.ts` (server actions).
## Limits
- Training data is from four US states. Reviews in Hindi or Hinglish are not handled well yet.
- Replies are not posted to Google. The Google Business Profile API needs approval, so the owner copies the reply.
- The judge is a model, not a person. The blind test is the check that matters.
- TypeScript is pinned to 6.0.3 because `typescript-eslint` does not load on TS 7 yet.
## Credits
Dataset: Google Local Reviews, McAuley lab, UCSD. Please cite:
- Jiacheng Li, Jingbo Shang, Julian McAuley. *UCTopic: Unsupervised Contrastive Learning for Phrase Representations and Topic Mining.* ACL 2022.
- An Yan, Zhankui He, Jiacheng Li, Tianyang Zhang, Julian McAuley. *Personalized Showcases: Generating Multi-Modal Explanations for Recommendations.* SIGIR 2023.
MIT license.
I read the README and the repo layout. The numbers below are from there.
v3 writes short replies in the owner's style, but it often leaves out details from the review. Only 56% mention a detail, against 98% for the prompted models. The judge rates it "reads like a template" 77% of the time. It is like real owners in length but vaguer than them. The training filter wasn't strong enough to teach it to be specific.
Rough budget: teacher data about $0.5, one training run about $1, evals about $0.5. That leaves about $2.5 for a second try. Let me look at your Billing page to confirm the balance before I start.
/ab, eval chart, Tiger search).The deadline is Mon 12:29 PM IST, about 50 hours away. My order: v4 data and training today, eval tonight, post and visuals tomorrow, and republish Sunday night.
Shall I start with the v4 data and teacher sampling? I'd also like your OK before deleting the old checkpoints.
gpt oss woul be very costly , among our competitor have anyone used it?
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re
for f in glob.glob('body/*.json'):
try: x=json.load(open(f)); b=x['body_markdown']; u=x['user']['username']
except: continue
if not re.search('tinker',b,re.I): continue
ms=[m for m in re.finditer(r'gpt-?oss|deepseek|qwen3\.[568]-?(?:27b|35b|122b|397b)|kimi|llama-3\.3-70b|teacher',b,re.I)]
if not ms: continue
print('==',u,len(ms))
seen=[]
for m in ms[:12]:
s=max(0,m.start()-100)
if any(abs(s-q)<250 for q in seen): continue
seen.append(s); print(' ',b[s:m.start()+160].replace('\n',' '))
E
== synthreaper 2
venance guard:** blocks fine-tuning on closed-API outputs, then regenerates references with an open teacher - **109 tests**, 10k-resample bootstrap CIs on every metric | Task | Best open model | Score [95% CI] | Verdict | |---|---|---|---| | Support triage |
icated-metrics rule. ## Prize Categories - **Best Use of Gemma:** 2B runs locally; 9B is the open teacher - **Best Use of Tinker:** LoRA training in 85s for $1.03
== coffeeandcommits 2
ages, same system prompt, exact match on every field. Baselines are zero-shot: the untouched 4B and gpt-oss-120b. | System | valid JSON | action | date | items | prefs | time | **all fields** | p50 latency | $ / 1k msgs | |---|---|---|---|---|---|---|---|---|
-|---|---| | Qwen3.5-4B zero-shot | 72% | 56% | 56% | 53% | 67% | 66% | **30%** | 2.1 s | $0.52 | | gpt-oss-120b zero-shot | 98% | 96% | 50% | 86% | 96% | 96% | **38%** | 2.8 s | $0.56 | | Qwen3.5-4B + Dabba LoRA | 100% | 100% | 100% | 100% | 100% | 99% | **98
== p0rt 8
more) and translated their 720 segments twice: with OPUS-MT, an open 2020 model, locally, and with DeepSeek-V3.1, an open 671B LLM, on Tinker. Nothing was tuned on these 1,440 translations. **The labels.** Three blind AI annotators (an MQM rater, a post-edit
**The surprise is the MT systems:** of the 84 major errors, 80 are OPUS-MT's (11% of its segments). DeepSeek-V3.1 made 4 (0.6%); seven of the ten pages have none. | 1,440 translations, 84 Major | AUROC [95% CI] | PR-AUC | Major errors in the riskiest 10% | De
alone the gap is within noise (−0.013, CI −0.051 to +0.017); Gemma's lead comes mostly from ranking DeepSeek's clean segments lower. - **The default line is far too quiet here:** 17 of its 22 flags are real, but that is a fifth of the errors (48% on WMT test),
flags all three). - **Quiet where it matters:** v1's 5 false flags are all minor errors, only 2 on DeepSeek's 716 clean segments (Gemma 16, base 25), where a real error is one in 180. The publishers' own Spanish, scored as MT, gets 3 flags in 720 (Gemma 79).
lled $6.46 in all: $6.21 for Qwen3.5-4B (four training runs, probes and evaluations), $0.23 for the DeepSeek-V3.1 translations and $0.02 for checkpoint storage. DigitalOcean cost at most $1.64 at list price, and Sentry's free plan covered tracing. ## Prize Ca
31B on WMT at $0 per segment. The v2 and v3 Tinker runs decided what ships, and Tinker sampled the DeepSeek-V3.1 translations. - **Best Use of DigitalOcean.** Gemma 4 31B on serverless inference is the cloud baseline everywhere (12,671 calls) and generated th
unsloth's GGUF; Gemma 4 31B (Apache-2.0); OPUS-MT en-es (Tiedemann & Thottingal, 2020, Apache-2.0); DeepSeek-V3.1 (MIT); Kokoro-82M (Apache-2.0) narrates the video. - **AI disclosure:** the code and this post were written with AI assistance (Claude Code). Gemm
== anishisbusy 3
(half of them 1 to 3 stars) with four writers: the same 4B with a careful prompt, my fine-tuned 4B, Qwen3.8-27B with the same prompt, and the real owner's reply. A judge from a different model family (gpt-oss-120b, also on Tinker) saw all four in random order
front desk". If Backboard is down, the app falls back to all facts. ### The promise check Groq (`gpt-oss-20b`) lists every promise in the draft and checks it against the shop facts. Anything unbacked flips the stamp to "Check" and shows the line. A keyword
== nicebills 3
ople are dropped, and each person is cropped exactly the way the game crops a player. The untuned **Qwen3.6-35B-A3B** on Tinker then cleaned the labels: a train/val example was kept only if it agreed with the rule. The test set never touched the rules or the t
| d1-omni-600M, CPU | 63.1% | 0.629 | 65 | 17 | | Qwen3.5-4B, untuned | 87.4% | 0.871 | 27 | 1 | | Qwen3.6-35B-A3B, untuned | 88.7% | 0.882 | 19 | 6 | | Qwen3.5-4B + LoRA, first run | 87.8% | 0.874 | 22 | 5 | | **Qwen3.5-4B + LoRA, second run** | **93.2%** |
== chestly_ace 2
fix, plain and short. Gemma isn't available through Tinker, so I fine-tuned Qwen3.5-4B on filtered teacher dialogues instead. The untuned model passed a frozen 8-criteria rubric 68% of the time; after tuning, 98%. Measured against the deployed cloud Gemma duc
mma.** Writes the breadcrumb, the nudge, the walk-mode pick, and the return brief, and was also the teacher model behind the fine-tuning data and the eval judge. **Best Use of TabPFN.** Classifies each 5-minute window as stuck or flow from six local signals w
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
You're right about the cost. Nobody used gpt-oss-120b as a teacher.
I also checked your own usage. Judging 300 reviews with gpt-oss-120b cost $0.27, and 300 replies from Qwen3.8-27B cost $0.15. That is about $0.0005 per reply for the 27B. 6,000 reviews with 4 candidates each would be about $12, which is far too much.
Revised plan, with no gpt-oss anywhere:
- Teacher: Qwen3.6-35B-A3B, which nicebills used. If it's too weak, use the 27B on a smaller set.
- Size: about 2,000 reviews with 2 candidates each, about 4,000 generations. That should cost around $1 or less. I'll check Tinker's price list before I run it.
- Filtering: do it in code, not with an LLM judge. The rules are word count, stock-phrase list, whether it repeats a detail from the review, and whether any promise is outside the shop facts. That costs nothing.
- Final eval: use a small judge sample. Most of the numbers come from the code rules.
Total should be around $2–2.5, which leaves room for a second attempt.
Want me to start by checking Tinker's current prices, then build the teacher data script?
yes check prices and build the teacher data script
{"url":"https://thinkingmachines.ai/tinker/","prompt":"Find the pricing table: per-million-token prices (prefill, sample, train) for each model, especially Qwen3.5-4B, Qwen3.6-35B-A3B, Qwen3.8-27B, gp...
The pricing table isn't in the content you provided. The page was cut off ("[Content truncated due to length...]") before any pricing section, so I can't list the per-million-token prices for prefill, sample, or train, or the storage price.
The visible text only points to pricing without listing numbers:
- In "Pay per token," the page says: "Pay for actual tokens used, not inactive time." It links to a models page (tinker-docs.thinkingmachines.ai/tinker/models/#pricing-terms) that likely holds the table.
- "Usage and costs" and "Storage reports" are listed as console features, with no prices.
Also, Qwen3.5-4B and Qwen3.6-35B-A3B don't appear in the visible text. The visible text mentions gpt-oss-120b, gpt-oss-20b, DeepSeek V3.1, Qwen3-4B, Qwen3-235B, and a "Qwen 3.8 27B" in the Krea section, but none with pricing.
[WebFetch note: this page's text is 135298 characters long and the answer above covers only characters 0 to 100000; the final 35298 were not read — to read on, call WebFetch again with the same url and offset: 100000.]
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && cat replydesk_ml/prompt.py && sed -n 1,400p scripts/eval.py
"""The prompt format shared by training, eval and the app.
Keep this in sync with app/lib/prompt.ts.
"""
SYSTEM = (
"You reply to Google reviews for {business}, a {shop_type}. "
"Write like the owner: short, warm, specific. "
"Answer what the reviewer actually said. Never promise refunds, discounts or anything the owner hasn't offered. "
"Don't sign off with a name."
)
def first_name(author: str) -> str:
name = (author or "").strip().split(" ")[0]
return name if name.isalpha() and len(name) > 1 else ""
def messages(row: dict, with_reply: bool = True) -> list[dict]:
user = f"Rating: {row['rating']}/5\n"
if name := first_name(row.get("author", "")):
user += f"Reviewer: {name}\n"
user += f"Review: {row['review'].strip()}"
out = [
{"role": "system", "content": SYSTEM.format(business=row["business"], shop_type=row["shop_type"])},
{"role": "user", "content": user},
]
if with_reply:
out.append({"role": "assistant", "content": row["reply"].strip()})
return out
"""Compare four reply writers on 300 held-out reviews from businesses never seen in training.
base Qwen3.5-4B, prompted (Tinker)
tuned Qwen3.5-4B, fine-tuned (Tinker)
big Qwen3.8-27B, prompted (Tinker)
real the reply the real owner posted
A judge model from a different family (gpt-oss-120b, sampled on Tinker) sees the four
replies to each review in random order with no labels and marks each one.
Generations and judgements are cached, so the script can be re-run safely.
uv run python scripts/eval.py gen # write replies (or: gen base big)
uv run python scripts/eval.py judge # judge them
uv run python scripts/eval.py report # numbers -> results.json + Tiger
"""
import asyncio
import json
import os
import random
import re
import statistics
import sys
import time
from pathlib import Path
from dotenv import load_dotenv
from openai import AsyncOpenAI, RateLimitError
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from replydesk_ml.prompt import messages # noqa: E402
load_dotenv(ROOT.parent.parent / ".env")
DATA = ROOT / "data"
OUT = DATA / "eval"
OUT.mkdir(exist_ok=True)
TUNED = "tinker://1cf358f7-dec5-50e7-b3fd-57db9f467d70:train:0/sampler_weights/final"
TINKER_PRICE = (0.33, 1.005) # $ per 1M prompt / sampled tokens, Qwen3.5-4B
JUDGE = "openai/gpt-oss-120b"
SYSTEMS = ["base", "tuned", "big", "real"]
tinker = AsyncOpenAI(
base_url="https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1", api_key=os.environ["TINKER_API_KEY"]
)
CLICHES = re.compile(
r"valued customer|apologi[sz]e for any inconvenience|sorry for any inconvenience|thank you for taking the time"
r"|we strive|your feedback is (?:important|valuable)|we value your feedback|don'?t hesitate|rest assured"
r"|we pride ourselves|reach out to (?:us|our)|please contact us|at your earliest convenience|we take .{0,20}seriously"
r"|exceed your expectations|top priority|look forward to serving you",
re.I,
)
def load(name: str) -> list[dict]:
p = OUT / name
return [json.loads(line) for line in p.open()] if p.exists() else []
async def call(client, sem, model, msgs, **kw):
async with sem:
for attempt in range(8):
try:
t = time.time()
r = await client.chat.completions.create(model=model, messages=msgs, **kw)
return r, time.time() - t
except RateLimitError:
await asyncio.sleep(min(60, 5 * 2**attempt))
raise RuntimeError("rate limited too many times")
async def generate(system: str, rows: list[dict]) -> None:
path = OUT / f"gen_{system}.jsonl"
done = {r["i"] for r in load(path.name)}
sem = asyncio.Semaphore(4)
async def one(i, row):
msgs = messages(row, with_reply=False)
model = {"base": "Qwen/Qwen3.5-4B", "tuned": TUNED, "big": "Qwen/Qwen3.8-27B"}[system]
r, dt = await call(tinker, sem, model, msgs, max_tokens=220, temperature=0.7, extra_body={"reasoning_effort": False})
# Only the 4B price is known; the 27B cost is left out rather than guessed.
cost = None
if system != "big":
cost = (r.usage.prompt_tokens * TINKER_PRICE[0] + r.usage.completion_tokens * TINKER_PRICE[1]) / 1e6
text = (r.choices[0].message.content or "").strip()
return {"i": i, "reply": text, "latency_s": round(dt, 2), "cost_usd": cost, "tokens": [r.usage.prompt_tokens, r.usage.completion_tokens]}
todo = [(i, row) for i, row in enumerate(rows) if i not in done]
with path.open("a") as f:
for coro in asyncio.as_completed([one(i, row) for i, row in todo]):
f.write(json.dumps(await coro) + "\n")
f.flush()
print(f"{system}: {len(load(path.name))} replies")
JUDGE_PROMPT = """You are judging replies a small business owner could post under a Google review.
Business: {business} ({shop_type})
Rating: {rating}/5
Review: {review}
Replies (random order):
{replies}
For each reply decide:
- detail: true if it responds to a specific thing this reviewer mentioned (not just the rating or a generic thanks).
- promise: true if it promises or offers something specific the owner would have to deliver (refund, discount, free item, call-back, a fix by a date, a named staff action). Generic "we'll do better" is false.
- template: true if it reads like a stock corporate template that could be pasted under any review.
Then rank all replies from the one the owner should most want to post to the least.
Answer with JSON only:
{{"replies": {{"1": {{"detail": bool, "promise": bool, "template": bool}}, ...}}, "ranking": [best id, ..., worst id]}}"""
async def judge(rows: list[dict]) -> None:
gens = {s: {g["i"]: g["reply"] for g in load(f"gen_{s}.jsonl")} for s in SYSTEMS if s != "real"}
gens["real"] = {i: r["reply"] for i, r in enumerate(rows)}
path = OUT / "judge.jsonl"
done = {r["i"] for r in load(path.name)}
sem = asyncio.Semaphore(4)
async def one(i, row):
order = SYSTEMS[:]
random.Random(i).shuffle(order)
replies = "\n\n".join(f"[{k + 1}] {gens[s][i]}" for k, s in enumerate(order))
prompt = JUDGE_PROMPT.format(replies=replies, **row)
for _ in range(3):
r, _ = await call(
tinker, sem, JUDGE, [{"role": "user", "content": prompt}],
max_tokens=1500, temperature=0, extra_body={"reasoning_effort": "low"},
)
try:
text = r.choices[0].message.content or ""
d = json.loads(text[text.find("{") : text.rfind("}") + 1])
marks = {order[int(k) - 1]: v for k, v in d["replies"].items()}
ranking = [order[int(k) - 1] for k in d["ranking"]]
if set(marks) == set(SYSTEMS) and sorted(ranking) == sorted(SYSTEMS):
return {"i": i, "marks": marks, "ranking": ranking}
except (ValueError, KeyError, TypeError, IndexError):
pass
return None
todo = [(i, row) for i, row in enumerate(rows) if i not in done and all(i in gens[s] for s in SYSTEMS)]
with path.open("a") as f:
for coro in asyncio.as_completed([one(i, row) for i, row in todo]):
if res := await coro:
f.write(json.dumps(res) + "\n")
f.flush()
print(f"judged: {len(load(path.name))}")
def report(rows: list[dict]) -> None:
gens = {s: {g["i"]: g for g in load(f"gen_{s}.jsonl")} for s in SYSTEMS if s != "real"}
gens["real"] = {i: {"reply": r["reply"], "latency_s": None, "cost_usd": None} for i, r in enumerate(rows)}
judged = load("judge.jsonl")
n = len(judged)
res = {}
for s in SYSTEMS:
replies = [g["reply"] for g in gens[s].values()]
lat = [g["latency_s"] for g in gens[s].values() if g["latency_s"] is not None]
cost = [g["cost_usd"] for g in gens[s].values() if g["cost_usd"] is not None]
res[s] = {
"median_words": statistics.median(len(r.split()) for r in replies),
"cliche_rate": sum(bool(CLICHES.search(r)) for r in replies) / len(replies),
"mentions_detail": sum(j["marks"][s]["detail"] for j in judged) / n,
"made_up_promises": sum(j["marks"][s]["promise"] for j in judged) / n,
"template_rate": sum(j["marks"][s]["template"] for j in judged) / n,
"ranked_first": sum(j["ranking"][0] == s for j in judged) / n,
"win_rate_vs_base": None if s == "base" else
sum(j["ranking"].index(s) < j["ranking"].index("base") for j in judged) / n,
"latency_ms": statistics.median(lat) * 1000 if lat else None,
"cost_per_reply": statistics.mean(cost) if cost else None,
}
res["_meta"] = {"n_reviews": len(rows), "n_judged": n, "judge": JUDGE, "tuned_checkpoint": TUNED}
(OUT / "results.json").write_text(json.dumps(res, indent=2))
for s in SYSTEMS:
print(s.ljust(6), {k: (round(v, 3) if isinstance(v, float) else v) for k, v in res[s].items()})
save_to_tiger(res, rows, gens, n)
def save_to_tiger(res, rows, gens, n) -> None:
import psycopg
run_id = time.strftime("%Y%m%d-%H%M")
with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
cur.execute("DELETE FROM eval_results")
for s in SYSTEMS:
for metric, value in res[s].items():
if value is not None:
cur.execute(
"INSERT INTO eval_results (system, metric, value, n, run_id) VALUES (%s, %s, %s, %s, %s)",
(s, metric, float(value), n, run_id),
)
# 40 blind A/B pairs for the public "which would you post?" page.
cur.execute("DELETE FROM ab_pairs")
picks = random.Random(7).sample(range(len(rows)), 40)
for i in picks:
if i in gens["base"] and i in gens["tuned"]:
r = rows[i]
cur.execute(
"INSERT INTO ab_pairs (id, category, rating, review_text, base_reply, tuned_reply) VALUES (%s,%s,%s,%s,%s,%s)",
(f"t{i}", r["shop_type"], r["rating"], r["review"], gens["base"][i]["reply"], gens["tuned"][i]["reply"]),
)
print(f"saved to Tiger (run {run_id})")
async def run_async(step: str, rows: list[dict], only: list[str]) -> None:
# One event loop for everything: the API clients are bound to the loop they first ran on.
if step in ("gen", "all"):
await asyncio.gather(*(generate(s, rows) for s in only or ["base", "tuned", "big"]))
if step in ("judge", "all"):
await judge(rows)
def main() -> None:
rows = [json.loads(line) for line in (DATA / "test.jsonl").open()]
step = sys.argv[1] if len(sys.argv) > 1 else "all"
asyncio.run(run_async(step, rows, sys.argv[2:]))
if step in ("report", "all"):
report(rows)
if __name__ == "__main__":
main()
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && cat scripts/to_chat.py scripts/train.py && cat data/stats.json | head -40 && ls data/v3 data/eval runs/v3 | head -40 && head -c 1500 data/train_chat.jsonl && cat pyproject.toml
"""Write train/val JSONL in the chat format tinker-cookbook expects."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from replydesk_ml.prompt import messages # noqa: E402
DATA = Path(__file__).resolve().parent.parent / "data"
for split in ("train", "val"):
rows = [json.loads(line) for line in (DATA / f"{split}.jsonl").open()]
with (DATA / f"{split}_chat.jsonl").open("w") as f:
for r in rows:
f.write(json.dumps({"messages": messages(r)}) + "\n")
print(f"{split}_chat.jsonl: {len(rows)}")
"""LoRA fine-tune Qwen3.5-4B on Tinker with thinking turned off.
uv run python scripts/train.py v2 # full run, logs to runs/v2
uv run python scripts/train.py smoke # 3 steps to check the setup
v1 (2 epochs on unfiltered replies) learned to argue with unhappy reviewers.
v2 trains on the filtered set for 1 epoch; held-out loss stopped improving
after about 75 steps in v1 anyway.
"""
import asyncio
import sys
from pathlib import Path
from dotenv import load_dotenv
from tinker_cookbook import hyperparam_utils
from tinker_cookbook.renderers import TrainOnWhat
from tinker_cookbook.supervised import train
from tinker_cookbook.supervised.data import FromConversationFileBuilder
from tinker_cookbook.supervised.types import ChatDatasetBuilderCommonConfig
ROOT = Path(__file__).resolve().parent.parent
load_dotenv(ROOT.parent.parent / ".env")
MODEL = "Qwen/Qwen3.5-4B"
RENDERER = "qwen3_5_disable_thinking"
def config(name: str) -> train.Config:
smoke = name == "smoke"
common = ChatDatasetBuilderCommonConfig(
model_name_for_tokenizer=MODEL,
renderer_name=RENDERER,
max_length=2048,
batch_size=64,
train_on_what=TrainOnWhat.LAST_ASSISTANT_MESSAGE,
)
data = FromConversationFileBuilder(
common_config=common, file_path=str(ROOT / "data" / "train_chat.jsonl"), test_size=200, shuffle_seed=26
)
return train.Config(
log_path=str(ROOT / "runs" / name),
model_name=MODEL,
recipe_name="replydesk",
renderer_name=RENDERER,
dataset_builder=data,
learning_rate=hyperparam_utils.get_lr(MODEL),
lr_schedule="linear",
num_epochs=1,
lora_rank=32,
save_every=3 if smoke else 25,
eval_every=3 if smoke else 25,
max_steps=3 if smoke else None,
)
if __name__ == "__main__":
asyncio.run(train.main(config(sys.argv[1] if len(sys.argv) > 1 else "v3")))
{
"raw_pairs": 590054,
"clean_pairs": 50954
}ls: cannot access 'data/v3': No such file or directory
data/eval:
gen_base.jsonl
gen_big.jsonl
gen_tuned.jsonl
judge.jsonl
results.json
v2
runs/v3:
checkpoints.jsonl
code.diff
config.json
logs.log
metrics.jsonl
timing_spans.jsonl
{"messages": [{"role": "system", "content": "You reply to Google reviews for Dog & Bicycle Bakery & Caf\u00e9, a cafe/restaurant. Write like the owner: short, warm, specific. Answer what the reviewer actually said. Never promise refunds, discounts or anything the owner hasn't offered. Don't sign off with a name."}, {"role": "user", "content": "Rating: 5/5\nReviewer: llama\nReview: Surprisingly quirky atmosphere (in a good way), friendly baristas, excellent chai that's not overpriced! Unique baked goods, fast WiFi, and free parking! What more could you ask for? I also loved the plant lady mug I got! I love that the mugs are all different."}, {"role": "assistant", "content": "Hi Llama Liza! So glad you had such a pleasant experience. We think it's pretty great here as well - our baristas definitely make the atmosphere pretty special. Hope to see you again soon! :)"}]}
{"messages": [{"role": "system", "content": "You reply to Google reviews for CDA Bike Co., a retail. Write like the owner: short, warm, specific. Answer what the reviewer actually said. Never promise refunds, discounts or anything the owner hasn't offered. Don't sign off with a name."}, {"role": "user", "content": "Rating: 4/5\nReviewer: Blaize\nReview: Decided to edit my original post alittle. Company is friendly and very knowledgeable on their stuff. However, follow-through has been a little lacking. Originally scheduled a bike fit back in September, but the owner bailed. Then attempted to do the same with anoth[project]
name = "ml"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"backboard-sdk>=1.5.19",
"fastembed>=0.9.0",
"openai>=3.29.0",
"polars>=2.0.0",
"psycopg[binary]>=3.3.6",
"python-dotenv>=1.2.4",
"tinker>=0.33.1",
"tinker-cookbook>=0.5.7",
]
{"url":"https://tinker-docs.thinkingmachines.ai/tinker/models/","prompt":"Extract the full pricing table: for every model list prefill, sample, train price per million tokens, and storage price per GB...
**Storage:** $0.10 per GB per month.
**Training table** (USD per million tokens). For the Limited-time 50% discount models, the price shown is current, with the original price in parentheses.
| # | Model | Context | Prefill | Cached prefill | Sample | Train |
|---|---|---|---|---|---|---|
| 1 | Inkling | 64K | $1.87 ($3.74) | $0.374 | $4.68 ($9.36) | $5.61 ($11.22) |
| 2 | Inkling (256K) | 256K | $1.87 ($3.74) | $0.374 | $4.68 ($9.36) | $11.23 ($22.46) |
| 3 | Inkling-Small | 64K | $0.58 ($1.16) | $0.116 | $1.44 ($2.88) | $1.73 ($3.46) |
| 4 | Inkling-Small (256K) | 256K | $0.58 ($1.16) | $0.116 | $1.44 ($2.88) | $3.47 ($6.94) |
| 5 | Nemotron-3.5-Super-VL-120B-A12B | 64K | $0.57 ($1.14) | $0.114 | $1.44 ($2.88) | $1.276 ($2.552) |
| 6 | Nemotron-3.5-Super-VL-120B-A12B (256K) | 256K | $0.57 ($1.14) | $0.114 | $1.44 ($2.88) | $2.32 ($4.64) |
| 7 | Nemotron-3.5-Lightning-30B-A3B | 64K | $0.184 ($0.368) | $0.0368 | $0.495 ($0.99) | $0.44 ($0.88) |
| 8 | Nemotron-3.5-Lightning-30B-A3B (256K) | 256K | $0.184 ($0.368) | $0.0368 | $0.495 ($0.99) | $0.80 ($1.60) |
| 9 | Nemotron-3-Ultra-550B-A55B | 64K | $1.80 ($3.60) | $0.36 | $6.225 ($12.45) | $5.478 ($10.956) |
| 10 | Nemotron-3-Ultra-550B-A55B (256K) | 256K | $1.80 ($3.60) | $0.36 | $6.225 ($12.45) | $9.96 ($19.92) |
| 11 | Nemotron-3-Super-120B-A12B | 64K | $0.57 ($1.14) | $0.114 | $1.44 ($2.88) | $1.276 ($2.552) |
| 12 | Nemotron-3-Super-120B-A12B (256K) | 256K | $0.57 ($1.14) | $0.114 | $1.44 ($2.88) | $2.32 ($4.64) |
| 13 | Nemotron-3-Nano-30B-A3B (retiring Oct 23) | 64K | $0.195 | $0.039 | $0.495 | $0.44 |
| 14 | GLM-5.3 (256K) | 256K | $3.78 | $0.756 | $12.15 | $14.58 |
| 15 | GLM-5.3-Flash (256K) | 256K | $0.84 | $0.168 | $2.11 | $5.58 |
| 16 | Kimi-K2.6 | 32K | $2.205 | $0.441 | $5.49 | $4.84 |
| 17 | Kimi-K2.6 (128K) | 128K | $2.205 | $0.441 | $5.49 | $15.40 |
| 18 | Qwen3.8-27B | 64K | $0.6975 | $0.1395 | $5.595 | $4.103 |
| 19 | Qwen3.8-27B (256K) | 256K | $0.6975 | $0.1395 | $5.595 | $7.46 |
| 20 | Qwen3.6-35B-A3B | 64K | $0.45 | $0.09 | $1.335 | $1.177 |
| 21 | Qwen3.6-35B-A3B (256K) | 256K | $0.45 | $0.09 | $1.335 | $2.14 |
| 22 | Qwen3.6-27B (retiring Oct 23) | 64K | $1.86 | $0.372 | $5.595 | $4.103 |
| 23 | Qwen3.5-397B-A17B | 64K | $1.65 | $0.33 | $7.50 | $6.60 |
| 24 | Qwen3.5-397B-A17B (256K) | 256K | $1.65 | $0.33 | $7.50 | $12.00 |
| 25 | Qwen3.5-35B-A3B-Base | 64K | $0.54 | $0.108 | $1.335 | $1.177 |
| 26 | Qwen3.5-9B | 64K | $0.30 | $0.06 | $0.807 | $1.463 |
| 27 | Qwen3.5-9B-Base | 64K | $0.30 | $0.06 | $0.807 | $1.463 |
| 28 | Qwen3.5-4B | 64K | $0.33 | $0.066 | $1.005 | $0.737 |
| 29 | Qwen3.5-4B (256K) | 256K | $0.33 | $0.066 | $1.005 | $1.34 |
| 30 | Qwen3-8B | 32K | $0.195 | $0.039 | $0.60 | $0.44 |
| 31 | GPT-OSS-120B | 32K | $0.33 | $0.066 | $0.84 | $0.737 |
| 32 | GPT-OSS-120B (128K) | 128K | $0.33 | $0.066 | $0.84 | $2.33 |
| 33 | GPT-OSS-20B | 32K | $0.18 | $0.036 | $0.45 | $0.396 |
| 34 | DeepSeek-V4.1-Flash (256K) | 256K | $0.60 | $0.12 | $3.60 | $4.98 |
| 35 | DeepSeek-V3.1 (retiring Oct 23) | 32K | $1.695 | $0.339 | $4.215 | $3.718 |
**Notes:**
- The page doesn't show strikethrough formatting, so I treated the first price in each discounted pair as the original. The current price is the second value.
- **Llama models** are not in the training table. The page lists them as retired on June 12, 2026 (Llama-3.3-70B-Instruct, Llama-3.1-70B, Llama-3.1-8B, Llama-3.1-8B-Instruct, Llama-3.2-3B, Llama-3.2-1B), so no current prices are given.
- Qwen3-8B, DeepSeek-V3.1, and the Qwen3.5-4B, Qwen3.6-35B-A3B, Qwen3.8-27B, and GPT-OSS rows are included above.
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/data && wc -l train.jsonl val.jsonl test.jsonl && head -c 900 train.jsonl && echo && python3 -I -c "
import json,statistics as s
r=[json.loads(l) for l in open('train.jsonl')]
print(r[0].keys())
print('median reply words',s.median(len(x['reply'].split()) for x in r))
import collections;print(collections.Counter(x['shop_type'] for x in r)); print(collections.Counter(x['rating'] for x in r))
"; ls -la examples_pool.parquet pairs.parquet; ls v2 eval/v2 | head
6000 train.jsonl
300 val.jsonl
300 test.jsonl
6600 total
{"gmap_id":"0x535dce788dec912f:0x822bfe53ba92dd96","business":"Dog & Bicycle Bakery & Café","shop_type":"cafe/restaurant","categories":"Cafe|Restaurant","state":"Montana","author":"llama liza","rating":5,"review":"Surprisingly quirky atmosphere (in a good way), friendly baristas, excellent chai that's not overpriced! Unique baked goods, fast WiFi, and free parking! What more could you ask for? I also loved the plant lady mug I got! I love that the mugs are all different.","reply":"Hi Llama Liza! So glad you had such a pleasant experience. We think it's pretty great here as well - our baristas definitely make the atmosphere pretty special. Hope to see you again soon! :)"}
{"gmap_id":"0x5361c68aeac35903:0xe2781138c3938f32","business":"CDA Bike Co.","shop_type":"retail","categories":"Bicycle Shop|Bar|Bicycle rack|Bicycle rental service|Bicycle repair shop|Parking lot for bicycles|Ski repai
dict_keys(['gmap_id', 'business', 'shop_type', 'categories', 'state', 'author', 'rating', 'review', 'reply'])
median reply words 41.0
Counter({'cafe/restaurant': 2297, 'retail': 1481, 'home services': 710, 'dentist': 592, 'auto repair': 553, 'salon': 367})
Counter({5: 3344, 1: 1195, 3: 568, 2: 499, 4: 394})
-rw-rw-r-- 1 anish-kumar anish-kumar 2702318 Oct 13 12:22 examples_pool.parquet
-rw-rw-r-- 1 anish-kumar anish-kumar 85725775 Oct 13 11:49 pairs.parquet
eval/v2:
gen_tuned.jsonl
judge.jsonl
results.json
v2:
examples_pool.parquet
train_chat.jsonl
train.jsonl
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && grep -n -E "^def |^[A-Z_]+ = |re.compile" scripts/build_dataset.py | head -50; sed -n 1,60p scripts/build_dataset.py; ls ../lib; sed -n 1,60p ../lib/prompt.ts; ls ../../../.env* ../../.env* 2>/dev/null
Exit code 1
15:ROOT = Path(__file__).resolve().parent.parent
16:DATA = ROOT / "data"
17:SEED = 26
20:LOW_SHARE = 0.5 # share of 1-3 star reviews in every split
23:SHOP_TYPES = {
31:EXCLUDE = r"hotel|motel|inn$|resort|lodging|car dealer|bank|credit union|hospital$|insurance|apartment|casino|walmart|gas station|fast food"
33:CONTACT = re.compile(r"\d{3}[\s.\-)]*\d{3}[\s.\-]*\d{4}|@[\w-]+\.\w|https?://|www\.", re.I)
34:SIGNOFF = re.compile(
39:STOP = set(
44:WORD = re.compile(r"[a-z]{4,}")
48:DEFENSIVE = (
53:ARGUING = (
58:ACKNOWLEDGES = r"(?i)sorry|apolog|thank|appreciate|hear|understand|fair|you.re right|feedback|regret"
59:STOCK = (
67:def good_replies(df: pl.DataFrame) -> pl.DataFrame:
82:def shop_type(categories: str) -> str | None:
92:def strip_signoff(reply: str) -> str:
102:def overlap(review: str, reply: str) -> int:
108:def normalise(reply: str, author: str) -> str:
116:def main() -> None:
"""Turn raw review/reply pairs into clean train / val / test sets.
Filters out template replies, chains, replies with contact details and replies
that don't respond to anything the reviewer said, strips sign-offs (the app
adds the owner's own), balances star ratings and splits by business so no
business appears in two splits.
"""
import json
import re
from pathlib import Path
import polars as pl
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
SEED = 26
N_TRAIN, N_VAL, N_TEST = 6000, 300, 300
LOW_SHARE = 0.5 # share of 1-3 star reviews in every split
# Map Google's first category to the shop types ReplyDesk is for.
SHOP_TYPES = {
"auto repair": r"auto repair|mechanic|auto body|brake|transmission|oil change|tire shop|auto glass|car repair|smog|muffler|car wash|towing|auto electrical",
"salon": r"hair salon|beauty salon|barber|nail salon|day spa|massage|esthetician|waxing|tanning|tattoo",
"dentist": r"dentist|dental|orthodont|chiropract|optometr|physical therap|veterinar|animal hospital|pet groomer",
"cafe/restaurant": r"restaurant|cafe|coffee|bakery|pizza|diner|bar & grill|sandwich|brewery|food truck|ice cream|donut|deli|taco|burger|grill|pub$|bistro|barbecue",
"home services": r"plumber|electrician|hvac|heating|air conditioning|roofing|carpet clean|cleaning service|landscap|pest control|locksmith|handyman|contractor|painter|garage door|appliance repair|moving|storage|flooring|window",
"retail": r"store$|shop$|boutique|florist|jewelry|pharmacy|hardware|bike|thrift|gift|book|furniture|nursery|liquor|pet supply|sporting goods|music store",
}
EXCLUDE = r"hotel|motel|inn$|resort|lodging|car dealer|bank|credit union|hospital$|insurance|apartment|casino|walmart|gas station|fast food"
CONTACT = re.compile(r"\d{3}[\s.\-)]*\d{3}[\s.\-]*\d{4}|@[\w-]+\.\w|https?://|www\.", re.I)
SIGNOFF = re.compile(
r"(\s*(?:[-–—~]+\s*|(?i:thanks|thank you|sincerely|best|cheers|regards|warmly|best regards|kind regards|respectfully),?\s*\n?\s*-?\s*)"
r"(?:the\s+)?[A-Z][\w.'&]*(?:\s+[A-Z&][\w.'&]*){0,4}"
r"(?:\s*[,&-]\s*(?i:owner|manager|gm|general manager|team|staff|dds|dmd|and the [\w ]+ team)[\w ]*)?\s*[.!]?\s*)$"
)
STOP = set(
"""the and for that with this was were are you your our have has had but not very they them their there
what when where which who will would could should from about just been more most also some than then into
over only really great good nice place time here back again best always never every very much well get got""".split()
)
WORD = re.compile(r"[a-z]{4,}")
# Real owners sometimes argue with reviewers or paste stock text. The model
# should learn neither, so the training set and the retrieval pool drop these.
DEFENSIVE = (
r"(?i)wrong (?:business|place|store|shop|location)|not (?:a|our) (?:customer|client)|no record|never (?:came|been|visited)"
r"|you must (?:be thinking|have)|confused|fake review|\bli(?:e|ar|ed|es)\b|competitor|false (?:review|claim|post|statement|accusation)|slander|defamat|i (?:believe|think) this (?:is|to be)|you can.?t get|impossible|not possible"
r"|attorney|lawyer|police|we did not see you|have no idea who|untrue|not true|how dare|ridiculous|sorry you feel|sorry you felt"
)
ARGUING = (
r"(?i)\byou (?:did not|didn.t|were not|weren.t|never|failed|refused|chose|should have|need to|have to understand|came in|bought)\b"
r"|\bwhy (?:you|would you)\b|\bhowever\b|\bactually\b|\bin fact\b|\bpolicy\b|as (?:stated|explained|mentioned)"
r"|\bclearly\b|\bobviously\b|\bunfortunately you\b|for your information|\bfyi\b"
)
ACKNOWLEDGES = r"(?i)sorry|apolog|thank|appreciate|hear|understand|fair|you.re right|feedback|regret"
STOCK = (
r"(?i)valued customer|apologi[sz]e for any inconvenience|sorry for any inconvenience|thank you for taking the time|we strive"
ab-sample.ts
ab.ts
backboard.ts
csv.ts
data-db.ts
data-mock.ts
data.ts
db
embed-worker.ts
facts.ts
format.ts
guardrail.ts
model.ts
prompt.ts
types.ts
use-embedder.ts
import type { Review, Shop, Tweak } from "./types";
// This prompt must match the training prompt exactly (fine-tuned Qwen3.5-4B).
// Do not add past-reply examples here. The model never saw them in training.
export type PromptInput = {
shop: Pick<Shop, "name" | "category" | "ownerName">;
review: Pick<Review, "rating" | "text" | "author">;
tweak?: Tweak;
facts?: string[];
};
const TWEAK_LINE: Record<Tweak, string> = {
shorter: "(Make it shorter.)",
warmer: "(Make it warmer.)",
offer_fix: "(Offer to fix it.)",
};
/** First word of the author name, only if it is all letters and longer than 1 char. */
export function reviewerFirstName(author: string): string | null {
const first = author.trim().split(/\s+/)[0] ?? "";
return /^\p{L}{2,}$/u.test(first) ? first : null;
}
export function buildPrompt({ shop, review, tweak, facts }: PromptInput) {
let system = `You reply to Google reviews for ${shop.name}, a ${shop.category}. Write like the owner: short, warm, specific. Answer what the reviewer actually said. Never promise refunds, discounts or anything the owner hasn't offered. Don't sign off with a name.`;
if (facts?.length) {
system += `\n\nShop facts (only promise what is listed here):\n${facts.map((f) => `- ${f}`).join("\n")}`;
}
const name = reviewerFirstName(review.author);
let user = `Rating: ${review.rating}/5\n${name ? `Reviewer: ${name}\n` : ""}Review: ${review.text}`;
if (tweak) user += `\n${TWEAK_LINE[tweak]}`;
return { system, user };
}
(eval):1: no matches found: ../../../.env*
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && sed -n 59,125p scripts/build_dataset.py; tail -5 scripts/build_dataset.py; ls -la ../../.env ../.env* 2>&1 | head; grep -c . data/test.jsonl
STOCK = (
r"(?i)valued customer|apologi[sz]e for any inconvenience|sorry for any inconvenience|thank you for taking the time|we strive"
r"|your feedback is (?:important|valuable)|we value your feedback|don.?t hesitate|rest assured|we pride ourselves"
r"|reach out to (?:us|our)|please contact us|at your earliest convenience|we take .{0,20}seriously|exceed your expectations"
r"|top priority|look forward to serving you"
)
def good_replies(df: pl.DataFrame) -> pl.DataFrame:
"""Replies worth learning from: not defensive, not stock text, and (v3) long
enough to say something and answering at least two things the reviewer said.
v2 without the last rule learned to post short, generic thank-yous."""
low = pl.col("rating") <= 3
return df.filter(
pl.col("reply").str.split(" ").list.len() >= 20,
pl.col("overlap") >= 2,
~pl.col("reply").str.contains(DEFENSIVE),
~pl.col("reply").str.contains(STOCK),
~low | ~pl.col("reply").str.contains(ARGUING),
~low | pl.col("reply").str.slice(0, 200).str.contains(ACKNOWLEDGES),
)
def shop_type(categories: str) -> str | None:
first = categories.split("|")[0].lower()
if not first or re.search(EXCLUDE, first):
return None
for name, pat in SHOP_TYPES.items():
if re.search(pat, first):
return name
return None
def strip_signoff(reply: str) -> str:
out = reply.strip()
for _ in range(2):
new = SIGNOFF.sub("", out).strip()
if new == out or len(new) < 25:
break
out = new
return out
def overlap(review: str, reply: str) -> int:
rv = {w for w in WORD.findall(review.lower()) if w not in STOP}
rp = {w for w in WORD.findall(reply.lower()) if w not in STOP}
return len(rv & rp)
def normalise(reply: str, author: str) -> str:
s = reply.lower()
first = (author or "").split(" ")[0].lower()
if first:
s = s.replace(first, "")
return re.sub(r"[^a-z ]", "", s).strip()
def main() -> None:
df = pl.read_parquet(DATA / "pairs.parquet")
n0 = df.height
# English only (mostly ASCII letters), and replies posted within 60 days.
df = df.filter(
pl.col("review").str.count_matches(r"[A-Za-z]") / pl.col("review").str.len_chars() > 0.6,
pl.col("reply").str.count_matches(r"[A-Za-z]") / pl.col("reply").str.len_chars() > 0.6,
(pl.col("reply_time") - pl.col("review_time")) < 60 * 86400 * 1000,
~pl.col("reply").str.contains(CONTACT.pattern),
(DATA / "stats.json").write_text(json.dumps({"raw_pairs": n0, "clean_pairs": df.height}, indent=2))
if __name__ == "__main__":
main()
-rw------- 1 anish-kumar anish-kumar 614 Oct 13 11:57 ../../.env
-rw-rw-r-- 1 anish-kumar anish-kumar 587 Oct 13 12:02 ../.env.example
-rw------- 1 anish-kumar anish-kumar 1924 Oct 13 12:34 ../.env.local
300
/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/replydesk_ml/rules.py
File created successfully at: /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/replydesk_ml/rules.py (file state is current in your context — no need to Read it back)
Rule checks are written. Next I'm adding shop facts to the training prompt, then the teacher script.
/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/replydesk_ml/prompt.py
The file /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/replydesk_ml/prompt.py has been updated successfully.
/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py
File created successfully at: /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py (file state is current in your context — no need to Read it back)
$ (uv run python scripts/teacher.py pilot qwen3.6-35b 30 > /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/pilot_q.txt 2>&1 &) ; uv run python scripts/teacher.py pilot gpt-oss-120b 30 2>&1 | tail -40; sleep 5; cat /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/pilot_q.txt | tail -40
gpt-oss-120b: 60 new candidates in pilot_gpt-oss-120b.jsonl
60 candidates, 44 pass all rules (73%), median 36.0 words
fails by rule: {'no_unbacked_promise': 9, 'no_arguing': 7, 'detail': 1}
cost $0.019, $0.00032 per candidate
Traceback (most recent call last):
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 268, in <module>
main()
~~~~^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 259, in main
summarise(path)
~~~~~~~~~^^^^^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 212, in summarise
rows = {r["i"]: r for r in load_rows()}
~^^^^^
KeyError: 'i'
qwen3.6-35b: 60 new candidates in pilot_qwen3.6-35b.jsonl
60 candidates, 44 pass all rules (73%), median 36.0 words
fails by rule: {'no_unbacked_promise': 9, 'no_arguing': 3, 'detail': 3, 'no_stock': 2}
cost $0.023, $0.00038 per candidate
Traceback (most recent call last):
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 268, in <module>
main()
~~~~^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 259, in main
summarise(path)
~~~~~~~~~^^^^^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 212, in summarise
rows = {r["i"]: r for r in load_rows()}
~^^^^^
KeyError: 'i'
$ python3 - <<'E'
import re
p='scripts/teacher.py'
s=open(p).read()
s=s.replace('rows = {r["i"]: r for r in load_rows()}\n for g in random','rows = dict(enumerate(load_rows()))\n for g in random')
open(p,'w').write(s)
E
uv run python - <<'E'
import json,sys,random
sys.path.insert(0,'.')
from replydesk_ml.rules import unbacked_promises
rows=[json.loads(l) for l in open('data/train.jsonl')]
for t in ['gpt-oss-120b','qwen3.6-35b']:
got=[json.loads(l) for l in open(f'data/v4/pilot_{t}.jsonl')]
print('\n#####',t)
print('promise fails:')
for g in got:
if not g['checks']['no_unbacked_promise']:
print(' ',unbacked_promises(g['reply'],g['facts']),'|facts',g['facts'][:2],'|',g['reply'][:150])
ok=[g for g in got if all(g['checks'].values())]
for g in random.Random(3).sample(ok,4):
r=rows[g['i']]
print(f"\n[{r['rating']}/5 {r['shop_type']}] {r['review'][:220]}\n facts:{g['facts']}\n -> {g['reply']}")
E
##### gpt-oss-120b
promise fails:
['credit'] |facts ['Ask the owner at the front desk if anything went wrong'] | Sorry for the trouble with our assistant manager and the way your credit return was handled. I hear you were frustrated trying to get the manager’s na
['free'] |facts [] | I’m thrilled Jamie could match you with the perfect boot and full outerwear set, making the rental feel personalized and hassle‑free. It’s great to he
['credit'] |facts ['Ask the owner at the front desk if anything went wrong'] | Sorry for the frustrating experience with our assistant manager. I understand he was unhelpful when you and your wife asked for a solution and the cre
['free'] |facts ['We clean up the work area before we leave'] | Curtis, I’m glad Corey’s careful quote and smooth handling of your 100‑year‑old home’s water line made the job feel fair and hassle‑free. It’s great t
['replace'] |facts ['We come back within 7 days for free if the problem returns', 'We clean up the work area before we leave'] | I’m sorry the duct joints weren’t sealed properly after the replacement. I’m glad you caught it, and if any issue shows up again we’ll come back withi
['free'] |facts [] | Hi Sharon, I’m thrilled you felt calm and pain‑free while Lisa and Dr. Siefer pulled your two teeth and that our personalized care eased your anxiety.
['free'] |facts [] | Hi Sharon, I’m thrilled you felt calm and pain‑free while Lisa and Dr. Siefer pulled your two teeth, and that our personalized care helped ease your a
['replace'] |facts [] | Amber, I’m sorry the oil change led to an O2 sensor replacement you didn’t expect and that the codes weren’t brought up. I’m glad we could stop the ye
['replace', 'replace'] |facts [] | Amber, I’m sorry we went ahead with the oil change and O2 sensor replacement despite your clear request not to replace anything. I’m glad the weird no
[5/5 home services] Not only amazing service but amazing people with huge hearts as well. I can not thank them enough for the service they provided me and my family.
We highly recommend!!
facts:['Free estimates, no obligation', 'We clean up the work area before we leave']
-> I'm thrilled you felt our service matched the big hearts we bring to every job, Matthew, and that your family’s experience was smooth. We always tidy up the work area before we leave.
[1/5 cafe/restaurant] My wings were cold and burnt to a crisp. How they managed to do that is beyond me, staff didn't even care
facts:[]
-> Sorry your wings were both cold and burnt, Gabby, and that the staff seemed indifferent. I’m truly sorry for that experience. I hope you’ll give us another chance to serve you properly.
[4/5 retail] It is a very nice little truck stop, and far superior to the Flying J across the street. One star off because the janitorial staff is already slacking on the bathrooms. The floor and urinals have actually not been clean
facts:[]
-> Jake, I hear you—those late‑day bathroom clean‑ups haven’t been up to par lately. We’ll make sure the staff steps up the schedule so the restrooms stay fresh for everyone.
[5/5 home services] Prime plumbing replaced the old main water line from the city line to my house. They took the time to come out and quote the job for the actual amount of work it would take instead of “flat rate” estimate some other comp
facts:['We clean up the work area before we leave']
-> Curtis, I’m glad Corey’s careful quoting and clear explanations helped you feel confident about the new main line, especially with your 100‑year‑old home’s quirks. We always clean up the work area before we leave, so it’s great to hear the yard stayed untouched.
##### qwen3.6-35b
promise fails:
['make this right'] |facts ['Ask the owner at the front desk if anything went wrong'] | Sorry to hear about your frustrating interaction with our assistant manager. If you are willing to discuss what happened, please ask for me at the fro
['call you'] |facts [] | James, we are sorry the Bozeman store failed to call you back about your pool salt order. We let you down on that promise and understand how frustrati
['free'] |facts ['We clean up the work area before we leave'] | Hi Curtis, we are so glad Corey handled those tricky old house quirks with ease and kept your yard damage-free. It means a lot that you appreciated ou
['replace'] |facts [] | Amber, we are sorry for the frustration regarding the unauthorized O2 sensor replacement and the cost. We should have respected your clear instruction
['call you'] |facts [] | Hi James, I am so sorry we failed to call you back about your pool salt. That is not how we treat our neighbors. We are fixing our communication proce
['replace'] |facts ['We clean up the work area before we leave'] | Curtis, we’re glad Corey handled those quirks in your 100-year-old home while keeping your yard undamaged. We appreciate you noticing how we clean up
['free'] |facts [] | Hi Sharon, I’m so glad Lisa and Dr. Siefer kept you calm and pain-free during your extractions. It means a lot that you feel relieved and confident wi
['free'] |facts [] | Hi Sharon, I am so glad Lisa and Dr. Siefer were able to keep you calm and pain-free during your extractions. It means a lot to us that you trust our
['replace', 'replace'] |facts [] | Amber, we are sorry the O2 sensor replacement cost more than you expected and that you felt we ignored your "do not replace" instructions. We will ret
[3/5 cafe/restaurant] Kinda cramped, super busy. Didn't have high chairs or boosters as far as we could tell but the food was pretty tasty with a short wait.
facts:['Ask for the owner at the counter if anything went wrong', 'We remake any drink that is not right, just tell the person at the counter', 'The kitchen closes 30 minutes before we do']
-> Caz, we are sorry the space felt cramped and busy, and that we lacked chairs for your kids. We hope you will visit again to enjoy our tasty food in our cozy setting.
[5/5 auto repair] Got a Malone DSG tune here on my 14 VW Passat TDi. Definitely woke the car up and firmed up the shifting. These two guys definitely know their stuff. Drove all the way from Helena to have it done. Shop is freakishly clea
facts:['Written estimate before any work starts']
-> Travis, it is great to hear your 14 VW Passat TDi feels alive again with that Malone DSG tune. We really appreciate you driving all the way from Helena, and we are glad our clean shop met your expectations.
[4/5 retail] It is a very nice little truck stop, and far superior to the Flying J across the street. One star off because the janitorial staff is already slacking on the bathrooms. The floor and urinals have actually not been clean
facts:[]
-> Jake, sorry the bathrooms weren’t clean enough for you on those recent late afternoon visits. We are working with our cleaning team to ensure the floors and urinals stay fresh for every customer who stops by.
[3/5 auto repair] Not terrible! Auto wash is decent.. The trash cans by vacuumes are often overflowing and yucky. Any time I've interacted with workers in little office they were unpleasant and unhappy to be there.
facts:['Free tire rotation with every oil change']
-> We apologize that our trash cans were overflowing and the staff seemed unhappy. We take pride in our decent car wash, so please come back to enjoy the clean service we provide.
$ python3 - <<'E'
p='replydesk_ml/rules.py'
s=open(p).read()
old=s[s.index('PROMISE = re.compile('):s.index('MIN_WORDS')]
new='''PROMISE = re.compile(
r"refund|discount|(?<![-\\w])free(?![-\\w])|complimentary|on us\\b|on the house|gift card|\\d+\\s?% off|compensat"
r"|call you|contact you|reach out to you|make (?:it|this|things) right"
r"|(?:issue|give|offer|apply|provide)\\w*\\s+(?:you\\s+)?(?:a\\s+|the\\s+)?(?:store\\s+)?credit"
r"|(?:we(?:.ll| will| can| would)|i(?:.ll| will))\\s+(?:gladly\\s+|happily\\s+)?(?:replace|re-?do)\\w*",
re.I,
)
'''
s=s.replace(old,new)
s=s.replace('''def unbacked_promises(reply: str, facts: list[str]) -> list[str]:
"""Promise words in the reply that no shop fact mentions."""
allowed = " ".join(facts).lower()
out = []
for m in PROMISE.finditer(reply):
word = m.group(0).lower()
if word not in allowed and word.rstrip("s") not in allowed:
out.append(word)
return out''','''def unbacked_promises(reply: str, facts: list[str]) -> list[str]:
"""Promise phrases in the reply that no shop fact mentions."""
allowed = " ".join(facts).lower()
out = []
for m in PROMISE.finditer(reply):
word = m.group(0).lower()
key = re.sub(r"^(?:we|i).(?:ll|will|can|would)\\s+|^(?:gladly|happily)\\s+", "", word).split()[-1]
if key not in allowed and key.rstrip("s") not in allowed:
out.append(word)
return out''')
open(p,'w').write(s)
p='scripts/teacher.py'
s=open(p).read()
# normalise typography
s=s.replace('''def clean(text: str) -> str:
text = re.sub(r"^(?:reply|response)\\s*:\\s*", "", text.strip(), flags=re.I)
return text.strip().strip('"').strip()''','''def clean(text: str) -> str:
"""Strip a leading label and the typography that gives away a model (curly quotes, em dashes)."""
text = re.sub(r"^(?:reply|response)\\s*:\\s*", "", text.strip(), flags=re.I)
text = text.replace("\\u2019", "'").replace("\\u2018", "'").replace("\\u201c", '"').replace("\\u201d", '"')
text = text.replace("\\u2011", "-").replace("\\u2010", "-").replace("\\u2013", "-")
text = re.sub(r"\\s*\\u2014\\s*", ", ", text)
return re.sub(r"\\s+", " ", text).strip().strip('"').strip()''')
# multi teacher
s=s.replace('''async def generate(teacher: str, n: int, path: Path) -> None:
model, p_in, p_out, extra = TEACHERS[teacher]
rows = pick_rows(n)''','''async def generate(teachers: str, n: int, path: Path) -> None:
"""teachers: one name, or several joined by commas (candidate k comes from teacher k mod len)."""
names = teachers.split(",")
rows = pick_rows(n)''')
s=s.replace(''' async def one(row, k):
user = messages(row, with_reply=False)[1]''',''' async def one(row, k):
teacher = names[k % len(names)]
model, p_in, p_out, extra = TEACHERS[teacher]
user = messages(row, with_reply=False)[1]''')
# recompute checks in summarise and build
s=s.replace(''' got = [json.loads(line) for line in path.open()]
ok = [g for g in got if all(g["checks"].values())]''',''' got = recheck([json.loads(line) for line in path.open()])
ok = [g for g in got if all(g["checks"].values())]''')
s=s.replace('''def summarise(path: Path) -> None:''','''def recheck(got: list[dict]) -> list[dict]:
"""Re-run the rules on saved replies, so rule fixes don't need new generations."""
rows = load_rows()
for g in got:
r = rows[g["i"]]
g["checks"] = check(r["review"], r["rating"], g["reply"], g["facts"])
return got
def summarise(path: Path) -> None:''')
s=s.replace(''' raw = [json.loads(line) for line in RAW.open()]
rows = {r["i"]: r for r in pick_rows(10**9)}''',''' raw = recheck([json.loads(line) for line in RAW.open()])
rows = {r["i"]: r for r in pick_rows(10**9)}''')
s=s.replace(''' print(f"{len(got)} candidates, {len(ok)} pass''',''' print("by teacher:", {t: f"{sum(all(g['checks'].values()) for g in got if g['teacher'] == t)}/{sum(g['teacher'] == t for g in got)}" for t in {g['teacher'] for g in got}})
print(f"{len(got)} candidates, {len(ok)} pass''')
s=s.replace(''' used = {r["i"] for r in map(json.loads, RAW.open())}''',''' used = {g["i"] for g in raw}''')
open(p,'w').write(s)
E
uv run python - <<'E'
import sys,json
sys.path.insert(0,'.')
from replydesk_ml.rules import unbacked_promises
print(unbacked_promises("pain-free and hassle-free, we'll replace it, we'll come back for free, a store credit",["Free re-check"]))
E
uv run python scripts/teacher.py pilot gpt-oss-120b 30 | head -8; uv run python scripts/teacher.py pilot qwen3.6-35b 30 | head -8
["we'll replace"]
Traceback (most recent call last):
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 285, in <module>
main()
~~~~^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 275, in main
asyncio.run(generate(teacher, n, path))
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/anish-kumar/.local/share/uv/python/cpython-3.14.7-linux-x86_64-gnu/lib/python3.14/asyncio/runners.py", line 205, in run
return runner.run(main)
~~~~~~~~~~^^^^^^
File "/home/anish-kumar/.local/share/uv/python/cpython-3.14.7-linux-x86_64-gnu/lib/python3.14/asyncio/runners.py", line 128, in run
return self._loop.run_until_complete(task)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
File "/home/anish-kumar/.local/share/uv/python/cpython-3.14.7-linux-x86_64-gnu/lib/python3.14/asyncio/base_events.py", line 720, in run_until_complete
return future.result()
~~~~~~~~~~~~~^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 208, in generate
print(f"{teacher}: {len(todo)} new candidates in {path.name}")
^^^^^^^
NameError: name 'teacher' is not defined. Did you mean: 'teachers'?
Traceback (most recent call last):
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 285, in <module>
main()
~~~~^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 275, in main
asyncio.run(generate(teacher, n, path))
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/anish-kumar/.local/share/uv/python/cpython-3.14.7-linux-x86_64-gnu/lib/python3.14/asyncio/runners.py", line 205, in run
return runner.run(main)
~~~~~~~~~~^^^^^^
File "/home/anish-kumar/.local/share/uv/python/cpython-3.14.7-linux-x86_64-gnu/lib/python3.14/asyncio/runners.py", line 128, in run
return self._loop.run_until_complete(task)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
File "/home/anish-kumar/.local/share/uv/python/cpython-3.14.7-linux-x86_64-gnu/lib/python3.14/asyncio/base_events.py", line 720, in run_until_complete
return future.result()
~~~~~~~~~~~~~^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/teacher.py", line 208, in generate
print(f"{teacher}: {len(todo)} new candidates in {path.name}")
^^^^^^^
NameError: name 'teacher' is not defined. Did you mean: 'teachers'?
$ sed -i 's/print(f"{teacher}: {len(todo)} new candidates/print(f"{teachers}: {len(todo)} new candidates/' scripts/teacher.py && rm data/v4/pilot_*.jsonl && uv run python scripts/teacher.py pilot gpt-oss-120b,qwen3.6-35b 30 2>&1 | tail -30
gpt-oss-120b,qwen3.6-35b: 60 new candidates in pilot_gpt-oss-120b,qwen3.6-35b.jsonl
by teacher: {'gpt-oss-120b': '28/30', 'qwen3.6-35b': '22/30'}
60 candidates, 50 pass all rules (83%), median 35.5 words
fails by rule: {'no_unbacked_promise': 2, 'detail': 3, 'no_arguing': 4, 'no_stock': 1, 'length': 1}
cost $0.021, $0.00035 per candidate
[5/5 retail] Best company I have worked with by far! The homie there Zack was super helpful, got me the top products for the best prices and still continued to help with questions well after my purchase! Installed
facts: ['Free repairs on anything we sold within 30 days', 'We can special order anything not on the shelf, usually 3 to 5 days', 'Ask the owner at the front desk if anything went wrong']
-> Hey Chris, I'm thrilled Zack could sort you out with the right parts at great prices and keep answering your questions. It's awesome the 3-hour kit fit your S2000 so smoothly after our fast shipping.
[1/5 retail] I would enter negative five stars if I could. The assistant manager that my wife and I dealt with, lacks the simple, basic people and customer service skills to run a portable toilet. Only after lit
facts: ['Ask the owner at the front desk if anything went wrong']
-> We are sorry to hear about your difficult interaction with our assistant manager and the experience regarding the credit return. Please ask the owner at the front desk next time if anything went wrong, as we want to address these issues directly.
[1/5 cafe/restaurant] My wings were cold and burnt to a crisp. How they managed to do that is beyond me, staff didn't even care
facts: []
-> Sorry your wings arrived cold and burnt, and that the staff didn't show any concern. I'm disappointed we missed the mark on something we usually take pride in. I'll make sure we do better next time.
[3/5 cafe/restaurant] Kinda cramped, super busy. Didn't have high chairs or boosters as far as we could tell but the food was pretty tasty with a short wait.
facts: ['Ask for the owner at the counter if anything went wrong', 'We remake any drink that is not right, just tell the person at the counter', 'The kitchen closes 30 minutes before we do']
-> Hi Caz. Sorry the space felt cramped and busy, and that we missed having high chairs or boosters available. I am glad the food was tasty and the wait was short though.
The script works: 83% of the pilot replies pass the rules, at about $0.00035 per reply. I'm now running the full generation, 2,500 reviews with 2 candidates each, about $1.75.
$ uv run python scripts/teacher.py gen gpt-oss-120b,qwen3.6-35b 2500 > data/v4/gen.log 2>&1
Command running in background with ID: bmuoqnzum. Output is being written to: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/bmuoqnzum.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
Prices checked, teacher script built, full run started in the background.
Prices (per million tokens, from Tinker's docs)
| Model | Sample | Train |
|---|---|---|
| gpt-oss-120b | $0.84 | $0.737 |
| Qwen3.6-35B-A3B | $1.335 | $1.177 |
| Qwen3.8-27B | $5.595 | $4.103 |
| Qwen3.5-4B | $1.005 | $0.737 |
gpt-oss-120b is cheaper than the 27B and the 35B. My earlier judge bill ($0.27) was high only because each judge call was long. As a teacher it's fine, so I'm using it alongside Qwen3.6-35B-A3B. Storage is $0.10 per GB-month.
What I built
- ml/scripts/teacher.py:
- Picks 2,500 train reviews, half of them 1 to 3 stars.
- Gives half of them 1 to 3 made-up shop facts, in the same format the app already sends.
- Asks the teacher for 2 replies each, with 3 real owner replies as voice examples.
- Keeps only replies that pass the rules.
- build adds half as many real owner replies that pass the same rules and writes data/v4/train_chat.jsonl.
- ml/replydesk_ml/rules.py: the free code checks. They cover length (15–55 words), a detail from the review, no stock phrases, no arguing, an apology on low stars, no sign-off, and no promise outside the shop facts. I also strip curly quotes and em dashes so the model doesn't learn them.
- prompt.py now supports shop facts, matching lib/prompt.ts.
Pilot: 60 candidates cost $0.021 and 83% passed the rules. gpt-oss-120b passed 28 of 30 and Qwen3.6-35B passed 22 of 30. The full run should cost about $1.75, and I'll tell you when it finishes. Then comes build, then v4 training, which should cost under $1 because the set is smaller than v3's.
The 59-post retry recovered nothing, so those posts stay unread.
ok
Generation still running. I'll pick up with build and the v4 training run when it finishes.
any update?
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/data/v4 && wc -l teacher_raw.jsonl; cat gen.log | tail -5; python3 -I -c "
import json
g=[json.loads(l) for l in open('teacher_raw.jsonl')]
print(len(g),'cost',round(sum(x['cost_usd'] for x in g),3))"
2383 teacher_raw.jsonl
2383 cost 0.82
Generation is about half done: 2,383 of 5,000 candidates, $0.82 spent so far. At this pace the full run should come to about $1.75 and finish in a few minutes. I'll run build as soon as it's done.
<task-notification>
<task-id>bmuoqnzum</task-id>
<tool-use-id>toolu_01KVX8du7HDTw1njmiZKosCU</tool-use-id>
<output-file>/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/bmuoqnzum.output</output-file>
<status>completed</status>
<summary>Background command "uv run python scripts/teacher.py gen gpt-oss-120b,qwen3.6-35b 2500 > data/v4/gen.log 2>&1" completed (exit code 0)</summary>
</task-notification>
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && cat data/v4/gen.log | head -12 && uv run python scripts/teacher.py build
gpt-oss-120b,qwen3.6-35b: 5000 new candidates in teacher_raw.jsonl
by teacher: {'gpt-oss-120b': '2207/2500', 'qwen3.6-35b': '2018/2500'}
5000 candidates, 4225 pass all rules (84%), median 36.0 words
fails by rule: {'no_unbacked_promise': 159, 'no_stock': 104, 'detail': 265, 'no_arguing': 310, 'acknowledges': 20, 'length': 10}
cost $1.721, $0.00034 per candidate
[5/5 salon] Being pregnant, I can't reach my toes anymore so I came here to get a pedicure done. There was only two people working there and they are both wonderful! It was a little bit of a wait, but definitely
facts: ['Walk-ins welcome on weekdays']
-> Hattie, I'm so glad you felt pampered despite the wait. Since we only have two people working, it took a little longer to get to you. I'm happy your color turned out lovely and the service was worth it.
[3/5 cafe/restaurant] I'm giving this place three starts due to poor service.
I understand it's busy but having one person wait to get served after everyone else has been served is lame. Also got our appetizers well after
1723 teacher + 861 real = 2584 examples, median 35.0 words, 877 with facts, 825 low-star
$ python3 - <<'E'
p='scripts/train.py'
s=open(p).read()
s=s.replace('''import asyncio
import sys''','''import asyncio
import os
import sys''')
s=s.replace('''file_path=str(ROOT / "data" / "train_chat.jsonl")''','''file_path=str(ROOT / "data" / os.environ.get("TRAIN_FILE", "train_chat.jsonl"))''')
s=s.replace('num_epochs=1,','num_epochs=int(os.environ.get("EPOCHS", 1)),')
s=s.replace('save_every=3 if smoke else 25,','save_every=3 if smoke else int(os.environ.get("SAVE_EVERY", 25)),')
s=s.replace('eval_every=3 if smoke else 25,','eval_every=3 if smoke else int(os.environ.get("SAVE_EVERY", 25)),')
open(p,'w').write(s)
E
TRAIN_FILE=v4/train_chat.jsonl EPOCHS=2 SAVE_EVERY=20 uv run python scripts/train.py v4 > runs/v4.log 2>&1
Command running in background with ID: ba4m5o1tt. Output is being written to: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/ba4m5o1tt.output. You will be notified when it completes — do not poll that file or sleep while waiting. Read the file only if the user asks for progress, or if you need a line it prints before you can go on, such as a server's ready line or port.
Teacher data is done and v4 training has started.
Teacher data
- 5,000 candidates cost $1.72. 84% passed the rules (gpt-oss-120b 2,207 of 2,500, Qwen3.6-35B 2,018 of 2,500).
- Rules that rejected the most: arguing with the reviewer (310), no detail from the review (265), unbacked promise (159), stock phrase (104).
- Final v4 set: 2,584 examples. That is 1,723 teacher replies plus 861 real owner replies that pass the same rules. Median 35 words, 877 with shop facts, 825 low-star.
Training
- Qwen3.5-4B, LoRA rank 32, 2 epochs, checkpoint every 20 steps. Expected cost is about $1.25.
- I added env options to train.py (TRAIN_FILE, EPOCHS, SAVE_EVERY). Defaults are unchanged, so v3 behaves as before.
- It's running in the background, and I'll start the eval when it ends.
Credit left should be about $3.4 before training, so about $2 after. That leaves room for one eval and a small retry.
Next: the eval. I'll build a held-out test with shop facts and some "promise-bait" reviews, like angry reviews asking for a refund. I'll score base vs v3 vs v4 with the free code rules, plus a small judge sample.
while the tinker part is going on could you like run a agent to see what tracks people ahve submitted for this week, i wnna see which track is has low , a submission can cover multiple tracks so have to count that in multiple
$ cd /home/anish-kumar/Code/hacktoberfest/week2 && sed -n 40,125p CONTEST.md
Questions the post should answer: Does running your own model keep costs low enough that the business works? Keep customers' data private? Let you fine-tune for a niche that a general API handles poorly? Avoid lock-in to someone else's pricing?
**Bonus points: land your first user.** Get it in front of someone, let them use it, and tell the story. Did they pay? Would they? What did they ask for next?
---
## 3. Judging
1. **Writing quality** (weighted most heavily). "A great project with a thin write-up won't win."
2. Relevance to the prompt and theme
3. Creativity
4. Technical execution
5. Use of partner technology (only if entering a partner category)
**Ties:** if judges tie, the entry with the most positive reactions on its DEV post wins.
---
## 4. Prizes
All prizes are **cash in USD**, not platform credits.
| Prize | Count | Reward |
|---|---|---|
| Overall winner | 1 | $250 USD + DEV++ membership + exclusive DEV badge |
| Featured category winner | 6 | $200 USD + exclusive DEV badge |
| Partner category winner | 11 | $100 USD + exclusive DEV badge |
| Every valid entry | all | Completion badge on DEV profile |
**Each participant can win at most one prize in this contest.** Entering more categories only gives more chances to win one prize. The prizes don't add up.
Partner credits (Tinker, Render, Backboard, free .tech domain) are separate free perks for building, claimed at https://hacktoberfest.com/my/promos. They are not prizes.
### Claiming a cash prize
- DEV emails the address on your DEV profile within 10 business days of the announcement.
- You may need to sign an affidavit of eligibility and publicity/liability release, and give tax info (W-9 or equivalent), **within 7 business days** of the first email. Missing this can forfeit the prize.
- Prizes are delivered 2–4 weeks after the paperwork is accepted.
- The rules don't say how the money is paid (bank, PayPal, etc.).
- Winners pay any taxes on prizes.
- Team prizes go to the team. DEV doesn't split them.
---
## 5. Prize categories (17)
To enter a category, the project must **meaningfully use** that technology and the post must **name the category** in the "Prize Categories" section. One project can enter every category it really uses.
"Week 1 entries" = week 1 posts that listed that category in their Prize Categories section (from my count on Oct 13: 1,689 week 1 posts read). "Weekend" = the same count for the weekend challenge (from week1/PLAN.md).
### Featured categories ($200)
| Category | What counts | Week 1 entries | Weekend | Usable for us? |
|---|---|---|---|---|
| Render | Use Render as the AI runtime, to host an agent's front end, or to run Hermes or OpenClaw | 226 | 172 | Crowded |
| TabPFN | Use TabPFN (Prior Labs) to forecast, predict, classify or spot anomalies from historical data like a CSV. TabPFN-3.5 recommended (free non-commercial license at platform.priorlabs.ai). Prior Labs favors TabPFN inside a wider prediction agent | 64 | 34 | Yes, used it in week 1 |
| Tinker | Use Thinking Machines' Tinker to fine-tune a model for a task and show a clear gain in performance, latency or cost over a baseline | 27 | 28 | Possible, needs a real fine-tune + before/after |
| Arduino | Build with an Arduino UNO Q: run a model on the board, build a physical agent, or optimize a model with Qualcomm AI Hub | 9 | 7 | No board |
| DigitalOcean | Host the app or agent on DigitalOcean, run an open-weight model on a GPU Droplet, or build an agent on the Gradient AI Platform | 17 | 16 | No: needs a card or PayPal |
| Gemma | Use Gemma (Google's open-weight model): run it locally, fine-tune it, or serve it through Google Cloud or another provider | 575 | 441 | Very crowded |
### Partner categories ($100)
| Category | What counts | Week 1 entries | Weekend | Usable for us? |
|---|---|---|---|---|
| Backboard | Build with R-CLI (Backboard's open-source terminal coding agent), compare open-weight models through one Backboard API key, or give an open-source project's assistant memory and RAG | 23 | — | Possible |
| ElevenLabs | Give an open-source agent a voice, transcribe audio for a local model, or generate narration for the demo | 106 | — | Crowded |
| Entire | Share the agent sessions behind the project in the write-up, or search past agent work to explain why code exists | 38 | 27 | Possible |
| GitHub Copilot | Build with Copilot's coding agent, Copilot CLI or the Copilot app, automate with GitHub Actions, or review PRs with Copilot | 65 | — | Medium |
| Mastra | Orchestrate an agent over open models, add memory and tools to a chat agent, or build a workflow that triages GitHub issues | 30 | 23 | Yes, used it in week 1 |
| MongoDB Atlas | Atlas Vector Search for retrieval, long-term memory for an agent, or the database behind an app on an open-weight model | 54 | — | Medium |
| Sentry Agent Tracing | Show the agent's setup and performance (latency, tokens, cost), what you found and how you debugged it. Include traces or screenshots | 43 | — | Possible |
| SerpApi | Give an agent live web search, ground a RAG app in fresh results, or track news for an open-source project | 52 | — | Medium |
| **.tech Domain** | Put the project live on a .tech domain claimed through Hacktoberfest and link it in the post. Free first year at hacktoberfest.com/my/promos (renews at $29.99/year) | **2** | — | **Yes, easy add-on** (check the claim needs no card) |
| Temporal | Wrap the agent in a Temporal workflow so it survives failures, retries flaky tool calls and resumes where it left off | 20 | 20 | Needs a place to run a worker |
| **Tiger Data** | Store embeddings with pgvector, run hybrid keyword and vector search for an agent, or let an agent manage a database through Tiger MCP | **17** | 11 | **Yes:** free plan, account exists, used in week 1 |
Partner categories can change between challenges. This list is the one on the week 2 page.
---
## 6. Rules that matter
- **New project only.** All code and the repo must be started and finished inside the Oct 12–18 window. No reusing the week 1 project (Nice Out). Pull requests to existing projects don't count.
- **Commits after the deadline** must be noted in the README, or the entry can be disqualified.
- **One submission per challenge** per person (or per team).
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && tail -5 runs/v4.log; python3 -I -c "
import json
m=[json.loads(l) for l in open('runs/v4/metrics.jsonl')]
print(len(m)); print([ (x.get('step'),x.get('test/nll'),x.get('train_mean_nll')) for x in m if 'test/nll' in x])"; ls runs/v4; cat runs/v4/checkpoints.jsonl | tail -2
cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re,collections
CATS={'Render':r'render','TabPFN':r'tabpfn','Tinker':r'tinker','Arduino':r'arduino','DigitalOcean':r'digital ?ocean','Gemma':r'gemma',
'Backboard':r'backboard','ElevenLabs':r'eleven ?labs','Entire':r'\bentire\b(?!\s+(?:project|app|pipeline|thing|stack|system|flow|workflow|time|day|week|session|build|process|codebase|repo))','GitHub Copilot':r'copilot','Mastra':r'mastra',
'MongoDB Atlas':r'mongo','Sentry':r'sentry','SerpApi':r'serp ?api','.tech Domain':r'\.tech\b','Temporal':r'temporal','Tiger Data':r'tiger ?data|tigerdata|tiger cloud'}
cnt=collections.Counter(); n=0; nosec=0; per=[]
for f in glob.glob('body/*.json'):
try: x=json.load(open(f)); b=x['body_markdown']
except: continue
m=re.search(r'#+\s*\**\s*prize categories?[^\n]*\n',b,re.I)
n+=1
if not m: nosec+=1; continue
sec=b[m.end():]
# cut at next h2 heading
nx=re.search(r'\n#{1,2}\s',sec); sec=sec[:nx.start()] if nx else sec
hit=[c for c,p in CATS.items() if re.search(p,sec,re.I)]
for c in hit: cnt[c]+=1
per.append(len(hit))
tot=n-nosec
print('posts read',n,'with Prize Categories section',tot,'without',nosec,'avg tracks per post',round(sum(per)/len(per),2))
for c,v in sorted(cnt.items(),key=lambda t:t[1]): print(f'{c:15}{v:5} {v/tot:.1%}')
for c in CATS:
if c not in cnt: print(c,0)
E
│ train_mean_bpb │ 0.280765 │
│ train_mean_nll │ 0.914879 │
└──────────────────────┴───────────┘
tinker_cookbook.checkpoint_utils:467 [[92mINFO[0m] Saved checkpoints: {'state_path': 'tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/weights/final', 'sampler_path': 'tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final'}
tinker_cookbook.supervised.train:584 [[92mINFO[0m] Training completed successfully
74
[(0, 2.0841572284698486, 1.9983305931091309), (20, 1.4395579099655151, 1.4685642719268799), (40, 1.3866184949874878, 1.1164716482162476), (60, 1.3782914876937866, 1.0431745052337646)]
checkpoints.jsonl
code.diff
config.json
logs.log
metrics.jsonl
timing_spans.jsonl
{"name": "000060", "batch": 23, "epoch": 1, "state_path": "tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/weights/000060", "sampler_path": "tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/000060", "elapsed_tokens": 846163}
{"name": "final", "batch": 0, "epoch": 2, "state_path": "tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/weights/final", "sampler_path": "tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final", "elapsed_tokens": 1027127}
posts read 741 with Prize Categories section 445 without 296 avg tracks per post 1.25
Arduino 2 0.4%
Tiger Data 8 1.8%
Temporal 8 1.8%
Tinker 10 2.2%
.tech Domain 11 2.5%
DigitalOcean 13 2.9%
Mastra 13 2.9%
Backboard 14 3.1%
Entire 15 3.4%
SerpApi 17 3.8%
MongoDB Atlas 20 4.5%
Sentry 20 4.5%
ElevenLabs 29 6.5%
TabPFN 31 7.0%
GitHub Copilot 35 7.9%
Render 84 18.9%
Gemma 227 51.0%
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto
python3 -I - <<'E'
import json,glob,re,collections
CATS={'Render':r'render','TabPFN':r'tabpfn','Tinker':r'tinker','Arduino':r'arduino','DigitalOcean':r'digital ?ocean','Gemma':r'gemma',
'Backboard':r'backboard','ElevenLabs':r'eleven ?labs','Entire':r'\bentire\b','GitHub Copilot':r'copilot','Mastra':r'mastra',
'MongoDB Atlas':r'mongo','Sentry':r'sentry','SerpApi':r'serp ?api','.tech Domain':r'\.tech\b','Temporal':r'temporal','Tiger Data':r'tiger ?data|tigerdata|tiger cloud'}
cnt=collections.Counter(); sec_n=0; fb_n=0; none=0; per=[]
for f in glob.glob('body/*.json'):
try: x=json.load(open(f)); b=x['body_markdown']
except: continue
m=re.search(r'prize categor(?:y|ies)[^\n]*\n',b,re.I)
if m:
sec=b[m.end():]; nx=re.search(r'\n#{1,2}\s',sec); sec=sec[:nx.start()] if nx else sec
hit={c for c,p in CATS.items() if re.search(p if c!='Entire' else r'\bentire\b(?!\s+(?:project|app|pipeline|thing|stack|system|flow|workflow|time|day|week|session|build|process|codebase|repo))',sec,re.I)}
sec_n+=1
else:
hit={c for c,p in CATS.items() if re.search(r'best use of[^\n]{0,15}'+p,b,re.I)}
if hit: fb_n+=1
else: none+=1; continue
for c in hit: cnt[c]+=1
per.append(len(hit))
tot=sec_n+fb_n
print('with section',sec_n,'| fallback "Best Use of X"',fb_n,'| no track found',none,'| avg tracks/post',round(sum(per)/len(per),2))
for c,v in sorted(cnt.items(),key=lambda t:t[1]): print(f'{c:15}{v:5} {v/tot:.1%}')
E
with section 499 | fallback "Best Use of X" 8 | no track found 234 | avg tracks/post 1.24
Arduino 2 0.4%
Tiger Data 10 2.0%
Temporal 10 2.0%
Tinker 12 2.4%
.tech Domain 14 2.8%
Mastra 14 2.8%
DigitalOcean 15 3.0%
Entire 15 3.0%
Backboard 15 3.0%
SerpApi 21 4.1%
MongoDB Atlas 24 4.7%
Sentry 24 4.7%
ElevenLabs 36 7.1%
GitHub Copilot 36 7.1%
TabPFN 36 7.1%
Render 97 19.1%
Gemma 250 49.3%
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
I did the count myself instead of spawning an agent. The data was already downloaded, so it was a quick script. It counts each post once per track it names in its "Prize Categories" section.
Of the 741 posts I read, 507 name at least one track, with 1.24 tracks per post on average. The other 234 name none, or use a format I can't parse.
| Track | Prize | Posts | Share |
|---|---|---|---|
| Arduino | $200 | 2 | 0.4% |
| Tiger Data | $100 | 10 | 2.0% |
| Temporal | $100 | 10 | 2.0% |
| Tinker | $200 | 12 | 2.4% |
| .tech Domain | $100 | 14 | 2.8% |
| Mastra | $100 | 14 | 2.8% |
| DigitalOcean | $200 | 15 | 3.0% |
| Entire | $100 | 15 | 3.0% |
| Backboard | $100 | 15 | 3.0% |
| SerpApi | $100 | 21 | 4.1% |
| MongoDB Atlas | $100 | 24 | 4.7% |
| Sentry | $100 | 24 | 4.7% |
| ElevenLabs | $100 | 36 | 7.1% |
| GitHub Copilot | $100 | 36 | 7.1% |
| TabPFN | $200 | 36 | 7.1% |
| Render | $200 | 97 | 19.1% |
| Gemma | $200 | 250 | 49.3% |
The counts miss the 59 posts I couldn't download and read only each post's own category list, so treat them as rough.
v4 training finished. The new model is at tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final. Its held-out loss was 1.378, but that is on different data from v3's 2.125, so the two aren't comparable. The real test is the eval. I'm building it next: a held-out set with shop facts and refund-bait reviews, scored for base vs v3 vs v4 with the free rules.
ok run the eval
/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval4.py
File created successfully at: /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval4.py (file state is current in your context — no need to Read it back)
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && uv run python scripts/eval4.py gen 2>&1 | tail -8 && uv run python scripts/eval4.py report
Exit code 1
careful: 300 replies
base: 300 replies
v4: 300 replies
v3: 300 replies
system pass_all detail no_stock no_arguing acknowledges length unbk unbk_refund_b median_words cost_per_repl prompt_tokens
base 0.27 0.90 0.87 0.90 0.95 0.37 0.19 0.50 63.00 0.0001 166.85
careful 0.87 0.94 0.99 0.94 1.00 1.00 0.02 0.33 30.00 0.0002 348.49
v3 0.38 0.59 0.96 0.96 0.91 0.88 0.10 0.25 33.00 0.0001 166.85
v4 0.87 0.91 1.00 0.98 0.99 1.00 0.02 0.17 34.00 0.0001 166.85
Traceback (most recent call last):
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval4.py", line 237, in <module>
report(rows)
~~~~~~^^^^^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval4.py", line 170, in report
print(s.ljust(8) + " ".join(
~~~~~~~~~^
(f"{res[s][c]:.4f}" if c == "cost_per_reply" else f"{res[s][c]:.2f}" if res[s][c] is not None else "-").rjust(13) for c in cols))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval4.py", line 171, in <genexpr>
(f"{res[s][c]:.4f}" if c == "cost_per_reply" else f"{res[s][c]:.2f}" if res[s][c] is not None else "-").rjust(13) for c in cols))
^^^^^^^^^^^^^^^
TypeError: unsupported format string passed to NoneType.__format__
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && python3 - <<'E'
p='scripts/eval4.py'
s=open(p).read()
s=s.replace('''(f"{res[s][c]:.4f}" if c == "cost_per_reply" else f"{res[s][c]:.2f}" if res[s][c] is not None else "-").rjust(13) for c in cols))''','''("-" if res[s][c] is None else f"{res[s][c]:.4f}" if c == "cost_per_reply" else f"{res[s][c]:.2f}").rjust(13) for c in cols))''')
open(p,'w').write(s)
E
uv run python scripts/eval4.py report | tail -8; uv run python scripts/eval4.py judge 2>&1 | tail -6
system pass_all detail no_stock no_arguing acknowledges length unbk unbk_refund_b median_words cost_per_repl prompt_tokens
base 0.27 0.90 0.87 0.90 0.95 0.37 0.19 0.50 63.00 0.0001 166.85
careful 0.87 0.94 0.99 0.94 1.00 1.00 0.02 0.33 30.00 0.0002 348.49
v3 0.38 0.59 0.96 0.96 0.91 0.88 0.10 0.25 33.00 0.0001 166.85
v4 0.87 0.91 1.00 0.98 0.99 1.00 0.02 0.17 34.00 0.0001 166.85
real 0.18 0.51 0.92 0.87 0.83 0.59 0.09 0.25 34.00 - -
refund-bait reviews: 12
v4 vs base: 52/100 = 52%
v4 vs careful: 55/100 = 55%
v4 vs v3: 81/100 = 81%
v4 vs real: 76/100 = 76%
$ python3 -I -c "
import json
r=json.load(open('data/eval/v4/results.json'))
for s in ['base','careful','v3','v4']:
x=r[s]; print(s,'cost/reply \$%.6f'%x['cost_per_reply'],'lat %.0f ms'%x['latency_ms'],'prompt tok %.0f'%x['prompt_tokens'],'with-facts unbacked %.3f'%x['unbacked_promise_rate_with_facts'],'pass',round(x['pass_all'],3))
import math
for k,n in [(52,100),(55,100),(81,100),(76,100)]:
p=k/n; print(k,'CI +-',round(1.96*math.sqrt(p*(1-p)/n),3))
"
base cost/reply $0.000136 lat 1120 ms prompt tok 167 with-facts unbacked 0.201 pass 0.27
careful cost/reply $0.000151 lat 900 ms prompt tok 348 with-facts unbacked 0.007 pass 0.867
v3 cost/reply $0.000099 lat 1110 ms prompt tok 167 with-facts unbacked 0.111 pass 0.377
v4 cost/reply $0.000097 lat 990 ms prompt tok 167 with-facts unbacked 0.000 pass 0.87
52 CI +- 0.098
55 CI +- 0.098
81 CI +- 0.077
76 CI +- 0.084
v4 is a big step up from v3, but it only ties the prompted model on the judge, so we haven't clearly won yet.
I ran 300 held-out reviews, with shop facts on about half. I scored five systems with the free rules, plus 100 pairwise judge calls per matchup using gpt-oss-120b.
| base (app prompt) | careful (long rulebook prompt) | v3 | v4 | real owners | |
|---|---|---|---|---|---|
| Passes all rules | 27% | 87% | 38% | 87% | 18% |
| Mentions a detail from the review | 90% | 94% | 59% | 91% | 51% |
| Median words | 63 | 30 | 33 | 34 | 34 |
| Promise not backed by facts (rows with facts) | 20% | 0.7% | 11% | 0% | – |
| Cost per reply | $0.000136 | $0.000151 | $0.000099 | $0.000097 | – |
| Prompt tokens | 167 | 348 | 167 | 167 | – |
Judge (v4 wins out of 100, about ±10 points):
- vs v3: 81%. This is the real fix.
- vs base: 52%, a coin flip.
- vs careful: 55%, a coin flip.
- vs real owners: 76%.
What I'd say honestly
- v3's problems are gone. Detail went from 59% to 91%, rule pass from 38% to 87%, and unbacked promises from 11% to 0%.
- v4 does not beat a careful prompt on quality. It ties it, since the long prompt is the same rulebook the teacher used. The win is that v4 does it with half the prompt (167 vs 348 tokens), about 36% cheaper per reply, and no instructions to maintain.
- Against the app's current untuned prompt, v4 is much better on the rules (87% vs 27%), but the judge still sees a tie.
- The rules were written from the same guide that made the training data, so some of this score is circular. Only the judge and human tests are independent. The refund-bait set is 12 reviews, too few to rely on.
How it compares to rivals: it is a clear measured gain (27% → 87%) plus a cost story, which puts you near Dabba and DuckWalk. The tie on the judge keeps you below nicebills and SecondRead, who have independent test sets.
Spent: about $2.6 on teacher data, training and eval. The balance should be around $2.2, but check the Billing page.
Next, in order of value:
1. Put v4 in the app and swap in new blind-test pairs, v4 vs the app-prompt base. The tie on the judge matters less if real people prefer v4, so send it to Pratik and a few others.
2. Raise the judge sample to 300 pairs (about $0.3) to narrow the ±10.
3. Rewrite the post around "I distilled a rulebook into a 4B model" and show these tables.
Do you want me to start with step 1, updating the app and the blind test?
yes update the app with v4
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && cat lib/model.ts && cat db/schema.sql && sed -n 1,80p lib/ab.ts && ls app/better app/ab && grep -n "TINKER_MODEL_PATH\|TINKER" .env.local .env.example 2>/dev/null | sed 's/=.*key.*/=<hidden>/' | cut -c1-120; git status --short | head
import { buildPrompt, type PromptInput } from "./prompt";
import type { DraftResult, ReplyExample } from "./types";
const DEFAULT_BASE_URL = "https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1";
// Tinker prices per million tokens (Qwen3.5-4B): prefill $0.33, sample $1.005.
const PREFILL_PER_TOKEN = 0.33 / 1e6;
const SAMPLE_PER_TOKEN = 1.005 / 1e6;
export type DraftInput = PromptInput & {
// Shown in the UI only. Never sent to the model.
examples?: ReplyExample[];
};
export function modelIsConfigured() {
return Boolean(process.env.TINKER_MODEL_PATH);
}
export async function draftReply(input: DraftInput): Promise<DraftResult> {
const started = Date.now();
const modelPath = process.env.TINKER_MODEL_PATH;
if (!modelPath) {
return { reply: mockReply(input), model: "offline-sample", latencyMs: Date.now() - started + 40, costUsd: 0 };
}
const { system, user } = buildPrompt(input);
const base = (process.env.TINKER_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
const res = await fetch(`${base}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.TINKER_API_KEY ?? ""}` },
body: JSON.stringify({
model: modelPath,
messages: [{ role: "system", content: system }, { role: "user", content: user }],
temperature: 0.7,
max_tokens: 220,
// Qwen3.5 is a hybrid thinking model. Tinker wants the boolean false, not "none".
reasoning_effort: false,
}),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`Tinker returned ${res.status}`);
const json = (await res.json()) as {
choices?: { message?: { content?: string | null } }[];
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const text = json.choices?.[0]?.message?.content?.trim();
if (!text) throw new Error("Tinker returned an empty reply");
const inTok = json.usage?.prompt_tokens ?? 0;
const outTok = json.usage?.completion_tokens ?? 0;
return {
reply: `${text} — ${input.shop.ownerName}`,
model: modelPath.startsWith("tinker://") ? "Qwen3.5-4B (fine-tuned)" : modelPath,
latencyMs: Date.now() - started,
costUsd: inTok * PREFILL_PER_TOKEN + outTok * SAMPLE_PER_TOKEN,
};
}
/* ---------- offline mock: deterministic, no network ---------- */
function hash(s: string) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
}
function firstName(a: string) {
return a.trim().split(/\s+/)[0] || "there";
}
function mockReply({ shop, review, tweak, facts }: DraftInput) {
const name = firstName(review.author);
const sign = `— ${shop.ownerName}`;
const pick = <T,>(arr: T[]) => arr[hash(review.text) % arr.length];
const fact = (re: RegExp) => facts?.find((f) => re.test(f));
const recheck = fact(/re-?check|redo|fix/i);
const update = fact(/whatsapp|update|call/i);
const desk = fact(/ask for/i);
let parts: string[];
if (review.rating <= 2) {
parts = [
pick([`${name}, you're right, and I'm sorry.`, `${name}, fair point. We should have done better.`]),
"Thanks for telling us what went wrong.",
update ? `${update}.` : "We will keep you in the loop next time.",
desk ? `${desk}.` : "Please ask for me directly.",
];
} else if (review.rating === 3 || review.rating === 4) {
parts = [
`Thanks ${name}, glad the main work went well.`,
pick(["We'll look at the point you raised.", "Your note is useful and we're acting on it."]),
"See you next time.",
];
} else {
parts = [
pick([`Thank you ${name}, that made our day.`, `Thanks ${name}, really good to hear.`]),
"We'll pass it on to the team.",
"See you again soon.",
];
}
if (tweak === "shorter") parts = parts.slice(0, 2);
if (tweak === "warmer") parts.splice(1, 0, "It means a lot to us that you took the time to write.");
if (tweak === "offer_fix") {
parts.splice(
parts.length - 1,
0,
recheck ? `${recheck.replace(/\.$/, "")}, just ask.` : "Call me and we will sort it out together.",
);
}
return `${parts.join(" ")} ${sign}`;
}
-- ReplyDesk schema for Tiger Cloud (Postgres + pgvector).
-- Mirrors lib/db/schema.ts. Apply with: psql "$DATABASE_URL" -f db/schema.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS vectorscale; -- DiskANN index
CREATE EXTENSION IF NOT EXISTS pg_textsearch; -- BM25 ranking
CREATE TABLE IF NOT EXISTS shops (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
category text NOT NULL,
city text NOT NULL DEFAULT '',
owner_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE shops ADD COLUMN IF NOT EXISTS backboard_assistant_id text; -- Backboard memory assistant
CREATE TABLE IF NOT EXISTS shop_facts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
shop_id uuid NOT NULL REFERENCES shops(id) ON DELETE CASCADE,
text text NOT NULL,
position integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
shop_id uuid NOT NULL REFERENCES shops(id) ON DELETE CASCADE,
author text NOT NULL,
rating smallint NOT NULL CHECK (rating BETWEEN 1 AND 5),
text text NOT NULL,
source text NOT NULL DEFAULT 'paste', -- paste | csv | sample
created_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL DEFAULT 'needs_reply' -- needs_reply | replied
);
CREATE INDEX IF NOT EXISTS reviews_shop_created_idx ON reviews (shop_id, created_at);
CREATE INDEX IF NOT EXISTS reviews_text_fts_idx ON reviews USING gin (to_tsvector('english', text));
CREATE TABLE IF NOT EXISTS replies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
review_id uuid NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
text text NOT NULL,
model text,
latency_ms integer,
cost_usd double precision,
sent_at timestamptz
);
CREATE TABLE IF NOT EXISTS reply_examples (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
shop_id uuid REFERENCES shops(id) ON DELETE CASCADE, -- NULL = curated example
category text NOT NULL,
review_text text NOT NULL,
reply_text text NOT NULL,
rating smallint,
embedding vector(384) -- BAAI/bge-small-en-v1.5
);
-- Hybrid search: BM25 for exact words, DiskANN for meaning, merged with RRF.
CREATE INDEX IF NOT EXISTS reply_examples_embedding_idx
ON reply_examples USING diskann (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS reply_examples_bm25_idx
ON reply_examples USING bm25 (review_text) WITH (text_config = 'english');
CREATE TABLE IF NOT EXISTS eval_results (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
system text NOT NULL, -- base | tuned | big | real
metric text NOT NULL, -- median_words | cliche_rate | mentions_detail | made_up_promises | win_rate_vs_base | cost_per_reply | latency_ms
value double precision NOT NULL,
n integer NOT NULL,
run_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ab_votes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pair_id text NOT NULL,
choice text NOT NULL, -- base | tuned | same
created_at timestamptz NOT NULL DEFAULT now(),
shop_id uuid REFERENCES shops(id) ON DELETE SET NULL
);
-- Blind A/B pairs drawn from the eval set (base vs tuned reply to the same review).
CREATE TABLE IF NOT EXISTS ab_pairs (
id text PRIMARY KEY,
category text NOT NULL,
rating smallint NOT NULL,
review_text text NOT NULL,
base_reply text NOT NULL,
tuned_reply text NOT NULL
);
import type { AbPair } from "./types";
export type BlindPair = {
id: string; author: string; rating: number; reviewText: string;
a: string; b: string; order: "base-first" | "tuned-first";
};
/** Puts the tuned reply on a random side for each pair (A or B). */
export function shuffleSides(pairs: AbPair[]): BlindPair[] {
return pairs.map((p) => {
const tunedFirst = Math.random() < 0.5;
return {
id: p.id, author: p.author, rating: p.rating, reviewText: p.reviewText,
a: tunedFirst ? p.tuned : p.base, b: tunedFirst ? p.base : p.tuned,
order: tunedFirst ? "tuned-first" : "base-first",
};
});
}
app/ab:
page.tsx
app/better:
page.tsx
.env.example:2:# If TINKER_MODEL_PATH is empty, the app returns offline mock replies.
.env.example:3:TINKER_API_KEY=
.env.example:4:TINKER_BASE_URL=https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1
.env.example:5:TINKER_MODEL_PATH=tinker://<run-id>/sampler_weights/<step>
.env.local:1:TINKER_API_KEY=[REDACTED-TINKER_API_KEY]
.env.local:2:TINKER_BASE_URL=https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1
.env.local:3:TINKER_MODEL_PATH=tinker://1cf358f7-dec5-50e7-b3fd-57db9f467d70:train:0/sampler_weights/final
M ml/replydesk_ml/prompt.py
M ml/scripts/train.py
?? ml/replydesk_ml/rules.py
?? ml/scripts/eval4.py
?? ml/scripts/teacher.py
$ wc -l app/better/page.tsx app/ab/page.tsx lib/data-db.ts lib/data-mock.ts lib/types.ts components/ab-test.tsx && cat app/better/page.tsx && grep -n -i "ab\|eval\|vote" lib/data-db.ts lib/types.ts lib/data.ts | head -60; which vercel; ls .vercel 2>/dev/null
109 app/better/page.tsx
24 app/ab/page.tsx
255 lib/data-db.ts
139 lib/data-mock.ts
94 lib/types.ts
103 components/ab-test.tsx
724 total
import type { Metadata } from "next";
import Link from "next/link";
import { getAbTally, getEvalResults } from "@/lib/data";
import type { EvalMetric, EvalSystem } from "@/lib/types";
export const metadata: Metadata = { title: "Is it better?" };
export const dynamic = "force-dynamic";
const SYSTEMS: { id: EvalSystem; label: string }[] = [
{ id: "base", label: "Qwen3.5-4B, prompted" },
{ id: "tuned", label: "ReplyDesk (Qwen3.5-4B, fine-tuned)" },
{ id: "big", label: "Qwen3.8-27B, prompted" },
{ id: "real", label: "Real owner reply" },
];
const pct = (v: number) => `${Math.round(v * 100)}%`;
const METRICS: { id: EvalMetric; label: string; help: string; fmt: (v: number) => string }[] = [
{ id: "median_words", label: "Median words", help: "Shorter is closer to how owners write.", fmt: (v) => String(Math.round(v)) },
{ id: "cliche_rate", label: "Cliché rate", help: "Replies with stock phrases like “we value your feedback”. Lower is better.", fmt: pct },
{ id: "mentions_detail", label: "Mentions a detail", help: "Reply points to something the customer actually said. Higher is better.", fmt: pct },
{ id: "made_up_promises", label: "Specific promises", help: "Offers a refund, discount, call-back or fix the owner would have to deliver. Lower is safer.", fmt: pct },
{ id: "template_rate", label: "Reads like a template", help: "Could be pasted under any review. Lower is better.", fmt: pct },
{ id: "ranked_first", label: "Judge's first pick", help: "Share of reviews where the judge ranked this reply best of the four.", fmt: pct },
{ id: "win_rate_vs_base", label: "Beats the prompted 4B", help: "Judge ranked it above the prompted base model.", fmt: pct },
{ id: "cost_per_reply", label: "Cost per reply", help: "Tokens in and out at Tinker's price. The 27B price is not public, so it is left out.", fmt: (v) => `$${v.toFixed(5)}` },
{ id: "latency_ms", label: "Latency", help: "Time to a full reply.", fmt: (v) => (v >= 1000 ? `${(v / 1000).toFixed(1)} s` : `${Math.round(v)} ms`) },
];
export default async function BetterPage() {
const [results, tally] = await Promise.all([getEvalResults(), getAbTally()]);
const votes = tally.base + tally.tuned + tally.same;
return (
<div className="page">
<h1>Is it better?</h1>
<p className="lede">
The fine-tuned model against the same model with a careful prompt, a much bigger open model, and what real owners wrote. Same held-out reviews from businesses the model never saw in training. A judge model from a different family (gpt-oss-120b) saw the four replies in random order with no labels.
</p>
{results ? (
<p className="note-sample" style={{ marginBottom: 18 }}>
Run {results.runId} · {results.n} held-out reviews · {new Date(results.createdAt).toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" })}
</p>
) : (
<div className="pending">
<span className="stamp">Pending</span>
<div>
<h2>Eval running, numbers land here.</h2>
<p>Nothing is filled in yet on purpose. This table reads straight from the eval results, so what you see later is what was measured.</p>
</div>
</div>
)}
<div className="table-wrap">
<table className="eval">
<caption className="sr-only">Eval results by system and metric</caption>
<thead>
<tr>
<th scope="col">Metric</th>
{SYSTEMS.map((s) => (
<th key={s.id} scope="col" className={s.id === "tuned" ? "tuned" : undefined}>{s.label}</th>
))}
</tr>
</thead>
<tbody>
{METRICS.map((m) => (
<tr key={m.id}>
<th scope="row">{m.label}<small>{m.help}</small></th>
{SYSTEMS.map((s) => {
const v = results?.values[s.id]?.[m.id];
return (
<td key={s.id} className={s.id === "tuned" ? "tuned" : undefined}>
{v == null ? <span className="dash" aria-label="not measured">—</span> : m.fmt(v)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{results && (
<section className="reading">
<h2>What the numbers say</h2>
<p>
Fine-tuning did what it was trained to do: the replies now sound like real owners. They are about the same length,
use far fewer stock phrases, and make fewer specific promises the owner would have to keep.
</p>
<p>
The judge model still prefers the prompted models. Their replies are longer and almost always name a detail
from the review, and judges tend to reward that. It ranks real owner replies low too, so the judge
is mostly measuring polish, not whether a shop owner would post it.
</p>
<p>
That question goes to people. On the <Link href="/ab">Blind test</Link> page you see one reply from each 4B model
side by side and pick the one you would send.
{votes > 0 && (
<> So far: {tally.tuned} for the fine-tuned reply, {tally.base} for the prompted one, {tally.same} “about the same” ({votes} votes).</>
)}
</p>
</section>
)}
<p className="lede" style={{ marginTop: 22, fontSize: 14 }}>
A dash means that number has not been measured.
</p>
</div>
);
}
lib/types.ts:3: { value: "auto repair", label: "Auto repair or garage" },
lib/types.ts:4: { value: "salon", label: "Salon or spa" },
lib/types.ts:5: { value: "dentist", label: "Clinic / dentist / vet" },
lib/types.ts:6: { value: "cafe/restaurant", label: "Café or restaurant" },
lib/types.ts:7: { value: "home services", label: "Home services" },
lib/types.ts:8: { value: "retail", label: "Shop or retail" },
lib/types.ts:55:export type EvalSystem = "base" | "tuned" | "big" | "real";
lib/types.ts:56:export type EvalMetric =
lib/types.ts:67:export type EvalResults = {
lib/types.ts:72: values: Partial<Record<EvalSystem, Partial<Record<EvalMetric, number>>>>;
lib/types.ts:75:export type AbPair = {
lib/types.ts:84:export type AbChoice = "base" | "tuned" | "same";
lib/types.ts:85:export type AbTally = Record<AbChoice, number>;
lib/data-db.ts:4:import { SAMPLE_AB_PAIRS } from "./ab-sample";
lib/data-db.ts:6:import { CATEGORIES, type AbChoice, type AbPair, type AbTally, type Category, EvalMetric, EvalResults, EvalSystem, Review, ReplyExample, Shop, ShopInput } from "./types";
lib/data-db.ts:8:const { shops, shopFacts, reviews, replies, replyExamples, evalResults, abVotes, abPairs } = schema;
lib/data-db.ts:217:export async function getEvalResults(): Promise<EvalResults | null> {
lib/data-db.ts:219: const [latest] = await db.select().from(evalResults).orderBy(desc(evalResults.createdAt)).limit(1);
lib/data-db.ts:221: const rows = await db.select().from(evalResults).where(eq(evalResults.runId, latest.runId));
lib/data-db.ts:222: const values: EvalResults["values"] = {};
lib/data-db.ts:224: const sys = r.system as EvalSystem;
lib/data-db.ts:225: (values[sys] ??= {})[r.metric as EvalMetric] = r.value;
lib/data-db.ts:230:export async function getAbPairs(): Promise<{ pairs: AbPair[]; isSample: boolean }> {
lib/data-db.ts:231: const rows = await getDb().select().from(abPairs);
lib/data-db.ts:232: if (!rows.length) return { pairs: SAMPLE_AB_PAIRS, isSample: true };
lib/data-db.ts:241:export async function getAbTally(): Promise<AbTally> {
lib/data-db.ts:243: .select({ choice: abVotes.choice, n: sql<number>`count(*)::int` })
lib/data-db.ts:244: .from(abVotes)
lib/data-db.ts:245: .groupBy(abVotes.choice);
lib/data-db.ts:246: const tally: AbTally = { base: 0, tuned: 0, same: 0 };
lib/data-db.ts:247: for (const r of rows) if (r.choice in tally) tally[r.choice as AbChoice] = r.n;
lib/data-db.ts:251:export async function saveVote(pairId: string, choice: AbChoice) {
lib/data-db.ts:254: await db.insert(abVotes).values({ pairId, choice, shopId: s.id });
lib/data.ts:5:// One switch: DATABASE_URL set -> Postgres (Tiger), otherwise the in-memory sample store.
lib/data.ts:6:const impl: typeof mock = process.env.DATABASE_URL ? db : mock;
lib/data.ts:16:export const getEvalResults = () => impl.getEvalResults();
lib/data.ts:17:export const getAbPairs = () => impl.getAbPairs();
lib/data.ts:18:export const getAbTally = () => impl.getAbTally();
lib/data.ts:19:export const saveVote = (...a: Parameters<typeof mock.saveVote>) => impl.saveVote(...a);
/home/anish-kumar/.local/share/pnpm/bin/vercel
project.json
README.txt
$ sed -n 50,94p lib/types.ts && sed -n 210,255p lib/data-db.ts && grep -n "draftReply\|facts" app/actions.ts | head -20 && grep -n -i "eval\|AbTally\|getAb" lib/data-mock.ts && cat components/ab-test.tsx | sed -n 1,103p && cat app/ab/page.tsx && cat .vercel/project.json | head -5; vercel whoami 2>&1 | tail -1
costUsd: number;
};
export type PromiseCheck = { text: string; ok: boolean; note: string };
export type EvalSystem = "base" | "tuned" | "big" | "real";
export type EvalMetric =
| "median_words"
| "cliche_rate"
| "mentions_detail"
| "made_up_promises"
| "template_rate"
| "ranked_first"
| "win_rate_vs_base"
| "cost_per_reply"
| "latency_ms";
export type EvalResults = {
runId: string;
createdAt: string;
n: number;
// values[system][metric]; missing means "not measured"
values: Partial<Record<EvalSystem, Partial<Record<EvalMetric, number>>>>;
};
export type AbPair = {
id: string;
reviewText: string;
author: string;
rating: number;
base: string;
tuned: string;
};
export type AbChoice = "base" | "tuned" | "same";
export type AbTally = Record<AbChoice, number>;
export type ShopInput = {
name: string;
category: Category;
city: string;
ownerName: string;
facts: string[];
pastReplies: string[];
};
if (seen.has(r.review_text)) continue;
seen.add(r.review_text);
out.push(toExample(r, false));
}
return out.slice(0, limit);
}
export async function getEvalResults(): Promise<EvalResults | null> {
const db = getDb();
const [latest] = await db.select().from(evalResults).orderBy(desc(evalResults.createdAt)).limit(1);
if (!latest) return null;
const rows = await db.select().from(evalResults).where(eq(evalResults.runId, latest.runId));
const values: EvalResults["values"] = {};
for (const r of rows) {
const sys = r.system as EvalSystem;
(values[sys] ??= {})[r.metric as EvalMetric] = r.value;
}
return { runId: latest.runId, createdAt: latest.createdAt.toISOString(), n: Math.max(...rows.map((r) => r.n)), values };
}
export async function getAbPairs(): Promise<{ pairs: AbPair[]; isSample: boolean }> {
const rows = await getDb().select().from(abPairs);
if (!rows.length) return { pairs: SAMPLE_AB_PAIRS, isSample: true };
return {
pairs: rows.map((r) => ({
id: r.id, author: "A customer", rating: r.rating, reviewText: r.reviewText, base: r.baseReply, tuned: r.tunedReply,
})),
isSample: false,
};
}
export async function getAbTally(): Promise<AbTally> {
const rows = await getDb()
.select({ choice: abVotes.choice, n: sql<number>`count(*)::int` })
.from(abVotes)
.groupBy(abVotes.choice);
const tally: AbTally = { base: 0, tuned: 0, same: 0 };
for (const r of rows) if (r.choice in tally) tally[r.choice as AbChoice] = r.n;
return tally;
}
export async function saveVote(pairId: string, choice: AbChoice) {
const db = getDb();
const s = await shopRow();
await db.insert(abVotes).values({ pairId, choice, shopId: s.id });
}
5:import { draftReply } from "@/lib/model";
6:import { factsForReview } from "@/lib/facts";
31: const [examples, facts] = await Promise.all([
36: factsForReview(shop, review.text),
38: const draft = await draftReply({ shop, review, tweak, examples, facts });
39: const promises = await checkPromises(draft.reply, facts);
49: return checkPromises(String(text).slice(0, 4000), shop.facts);
105: facts: z.array(z.string().trim().min(1).max(160)).max(20),
143: review.reply ? checkPromises(review.reply.text, shop.facts) : Promise.resolve([]),
3:import type { AbChoice, AbPair, AbTally, EvalResults, Review, ReplyExample, Shop, ShopInput } from "./types";
126:export async function getEvalResults(): Promise<EvalResults | null> {
127: return null; // real numbers land from the eval run; never invent them
129:export async function getAbPairs(): Promise<{ pairs: AbPair[]; isSample: boolean }> {
132:export async function getAbTally(): Promise<AbTally> {
133: const tally: AbTally = { base: 0, tuned: 0, same: 0 };
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { saveVoteAction } from "@/app/actions";
import { stars } from "@/lib/format";
import type { BlindPair } from "@/lib/ab";
type Vote = { pairId: string; choice: "a" | "b" | "same"; order: BlindPair["order"] };
const NEEDED = 10;
export function AbTest({ pairs }: { pairs: BlindPair[] }) {
const router = useRouter();
const [votes, setVotes] = useState<Vote[]>([]);
const i = votes.length;
const pair = pairs[i];
function vote(choice: Vote["choice"]) {
const v = { pairId: pair.id, choice, order: pair.order };
setVotes((x) => [...x, v]);
void saveVoteAction(v);
}
function again() {
setVotes([]);
router.refresh();
}
if (!pairs.length) return <p>No pairs to compare yet.</p>;
if (!pair) {
const side = (v: Vote) => (v.choice === "same" ? "same" : (v.choice === "a") === (v.order === "base-first") ? "base" : "tuned");
const tuned = votes.filter((v) => side(v) === "tuned").length;
const base = votes.filter((v) => side(v) === "base").length;
const same = votes.length - tuned - base;
const showTally = votes.length >= NEEDED;
return (
<div>
<h2 style={{ font: "600 26px var(--font-fraunces), serif", margin: "0 0 6px" }}>That was the last pair.</h2>
{showTally ? (
<div className="tally" role="group" aria-label="Your votes">
<div><b>{tuned}</b><span>picked the fine-tuned reply</span></div>
<div><b>{base}</b><span>picked the base model reply</span></div>
<div><b>{same}</b><span>about the same</span></div>
</div>
) : (
<p className="lede" style={{ margin: "0 0 22px" }}>
Totals show after {NEEDED} votes, so a few picks do not look like a result. You have {votes.length}.
</p>
)}
<h3 style={{ font: "600 18px var(--font-fraunces), serif", margin: "0 0 10px" }}>Which side was which</h3>
<ol className="reveal">
{pairs.map((p, n) => {
const v = votes[n];
const tunedLabel = p.order === "tuned-first" ? "A" : "B";
return (
<li key={p.id}>
<span>{p.author} · {p.rating}/5: “{p.reviewText.length > 70 ? p.reviewText.slice(0, 70) + "…" : p.reviewText}”</span>
<span>
Fine-tuned was <em>Reply {tunedLabel}</em>.{" "}
{v ? (v.choice === "same" ? "You said about the same." : `You picked Reply ${v.choice.toUpperCase()}.`) : ""}
</span>
</li>
);
})}
</ol>
<button className="btn primary" onClick={again}>Go again in a new order</button>
</div>
);
}
return (
<div>
<div className="ab-head">
<span>Pair {i + 1} of {pairs.length}</span>
<span className="ab-progress" aria-hidden="true">
{pairs.map((p, n) => <i key={p.id} className={n < i ? "done" : n === i ? "now" : ""} />)}
</span>
</div>
<div className="meta">
<span className="stars" aria-label={`${pair.rating} out of 5 stars`}>{stars(pair.rating)}</span>
<span>Google review</span>
</div>
<blockquote className="quote">“{pair.reviewText}”</blockquote>
<div className="by">— {pair.author}</div>
<div className="ab-pair" key={pair.id}>
<section className="slip l" aria-labelledby="ra">
<div className="label"><span id="ra">Reply A</span></div>
<p className="txt">{pair.a}</p>
<button className="btn" onClick={() => vote("a")}>A is better</button>
</section>
<section className="slip r" aria-labelledby="rb">
<div className="label"><span id="rb">Reply B</span></div>
<p className="txt">{pair.b}</p>
<button className="btn" onClick={() => vote("b")}>B is better</button>
</section>
</div>
<div className="ab-same">
<button className="btn" onClick={() => vote("same")}>About the same</button>
</div>
</div>
);
}
import type { Metadata } from "next";
import { AbTest } from "@/components/ab-test";
import { shuffleSides } from "@/lib/ab";
import { getAbPairs } from "@/lib/data";
export const metadata: Metadata = { title: "Blind test" };
export const dynamic = "force-dynamic";
export default async function AbPage() {
const { pairs, isSample } = await getAbPairs();
const blind = shuffleSides(pairs);
return (
<div className="page">
<h1>Blind test</h1>
<p className="lede">
Two replies to the same review. You are not told which one came from which model. Pick the one you would rather send.
</p>
{isSample && (
<p className="note-sample">These are hand-written sample pairs for now. Pairs from the real eval run replace them.</p>
)}
<AbTest pairs={blind} />
</div>
);
}
{"projectId":"prj_OfBARQJ2m34zHxxvpoo9rIwo3QWP","orgId":"team_zcPuIFC1rHjXzDXYvEPoPvFx","projectName":"replydesk"}╰──────────────────────────────────────────────────────────────────────────────╯
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && python3 - <<'E'
p='scripts/eval4.py'
s=open(p).read()
s=s.replace(''' uv run python scripts/eval4.py judge # pairwise judge, v4 vs each other system (100 reviews)
"""''',''' uv run python scripts/eval4.py judge # pairwise judge, v4 vs each other system (100 reviews)
uv run python scripts/eval4.py save # results + 40 blind v4-vs-careful pairs -> Tiger (the /better and /ab pages)
"""''')
s=s.replace('''async def main_async(step''','''def save(rows: list[dict]) -> None:
import psycopg
res = json.loads((OUT / "results.json").read_text())
judged = load("judge.jsonl")
g = gens(rows)
run_id = "v4-" + time.strftime("%Y%m%d-%H%M")
metrics = ["pass_all", "rule_detail", "rule_no_stock", "rule_no_arguing", "rule_acknowledges", "rule_length",
"unbacked_promise_rate", "unbacked_promise_rate_with_facts", "median_words", "latency_ms", "cost_per_reply",
"prompt_tokens"]
with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
for s in SYSTEMS:
for m in metrics:
if res[s].get(m) is not None:
cur.execute("INSERT INTO eval_results (system, metric, value, n, run_id) VALUES (%s,%s,%s,%s,%s)",
(s, m, float(res[s][m]), res[s]["n"], run_id))
w = [j["v4_won"] for j in judged if j["vs"] == s]
if w:
cur.execute("INSERT INTO eval_results (system, metric, value, n, run_id) VALUES (%s,%s,%s,%s,%s)",
(s, "v4_win_rate", sum(w) / len(w), len(w), run_id))
# Blind pairs: v4 against the careful prompt, the hardest honest opponent.
cur.execute("DELETE FROM ab_pairs")
for i in random.Random(11).sample(range(len(rows)), 40):
r = rows[i]
cur.execute(
"INSERT INTO ab_pairs (id, category, rating, review_text, base_reply, tuned_reply) VALUES (%s,%s,%s,%s,%s,%s)",
(f"v4-{i}", r["shop_type"], r["rating"], r["review"], g["careful"][i]["reply"], g["v4"][i]["reply"]),
)
print(f"saved to Tiger (run {run_id})")
async def main_async(step''')
s=s.replace(''' if step == "report":
report(rows)''',''' if step == "report":
report(rows)
if step == "save":
save(rows)''')
open(p,'w').write(s)
E
uv run python scripts/eval4.py save
saved to Tiger (run v4-20261017-1046)
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && python3 - <<'E'
import re
def sub(path, old, new, count=1):
s=open(path).read()
assert old in s, (path, old[:50])
open(path,'w').write(s.replace(old,new,count))
# types
sub('lib/types.ts','export type EvalSystem = "base" | "tuned" | "big" | "real";','export type EvalSystem = "base" | "careful" | "v3" | "v4" | "real";')
s=open('lib/types.ts').read()
i=s.index('export type EvalMetric ='); j=s.index('export type EvalResults')
s=s[:i]+'''export type EvalMetric =
| "pass_all"
| "rule_detail"
| "rule_no_stock"
| "rule_no_arguing"
| "rule_acknowledges"
| "rule_length"
| "unbacked_promise_rate"
| "unbacked_promise_rate_with_facts"
| "median_words"
| "latency_ms"
| "cost_per_reply"
| "prompt_tokens"
| "v4_win_rate";
'''+s[j:]
open('lib/types.ts','w').write(s)
# tally only counts votes on the current pairs (ids start with v4-)
sub('lib/data-db.ts',''' .from(abVotes)
.groupBy(abVotes.choice);''',''' .from(abVotes)
.where(like(abVotes.pairId, "v4-%"))
.groupBy(abVotes.choice);''')
E
sed -n 1,8p lib/data-db.ts
import { asc, desc, eq, sql } from "drizzle-orm";
import { getDb, schema } from "./db";
import { csvToReviews } from "./csv";
import { SAMPLE_AB_PAIRS } from "./ab-sample";
import { createAssistant, syncFacts } from "./backboard";
import { CATEGORIES, type AbChoice, type AbPair, type AbTally, type Category, EvalMetric, EvalResults, EvalSystem, Review, ReplyExample, Shop, ShopInput } from "./types";
const { shops, shopFacts, reviews, replies, replyExamples, evalResults, abVotes, abPairs } = schema;
Switching the app to v4: types and vote tally are done, now the /better page, the blind-test text and the model path.
$ python3 - <<'E'
def sub(path, old, new):
s=open(path).read(); assert old in s,(path,old[:60]); open(path,'w').write(s.replace(old,new,1))
sub('lib/data-db.ts','import { asc, desc, eq, sql } from "drizzle-orm";','import { asc, desc, eq, like, sql } from "drizzle-orm";')
# ---- /better page
p='app/better/page.tsx'
s=open(p).read()
i=s.index('const SYSTEMS'); j=s.index('export default async function')
s=s[:i]+'''const SYSTEMS: { id: EvalSystem; label: string }[] = [
{ id: "base", label: "Qwen3.5-4B, short prompt" },
{ id: "careful", label: "Qwen3.5-4B, long rulebook prompt" },
{ id: "v3", label: "Earlier tune (v3)" },
{ id: "v4", label: "ReplyDesk (v4, fine-tuned)" },
{ id: "real", label: "Real owner reply" },
];
const pct = (v: number) => `${Math.round(v * 100)}%`;
const METRICS: { id: EvalMetric; label: string; help: string; fmt: (v: number) => string }[] = [
{ id: "pass_all", label: "Passes every rule", help: "Short, answers the review, no stock phrase, no arguing, an apology on low stars, no unbacked promise. Higher is better.", fmt: pct },
{ id: "rule_detail", label: "Mentions a detail", help: "Reply repeats something the customer actually said. Higher is better.", fmt: pct },
{ id: "unbacked_promise_rate_with_facts", label: "Promises beyond the shop facts", help: "On reviews where the shop saved facts: refunds, discounts or call-backs the facts do not back. Lower is safer.", fmt: pct },
{ id: "median_words", label: "Median words", help: "Real owners write about 34.", fmt: (v) => String(Math.round(v)) },
{ id: "prompt_tokens", label: "Prompt size", help: "Tokens sent with every request. The tuned model needs no rulebook in the prompt.", fmt: (v) => String(Math.round(v)) },
{ id: "cost_per_reply", label: "Cost per reply", help: "Tokens in and out at Tinker's price for Qwen3.5-4B.", fmt: (v) => `$${v.toFixed(5)}` },
{ id: "latency_ms", label: "Latency", help: "Time to a full reply.", fmt: (v) => (v >= 1000 ? `${(v / 1000).toFixed(1)} s` : `${Math.round(v)} ms`) },
{ id: "v4_win_rate", label: "v4 wins head to head", help: "Judge (gpt-oss-120b) picked v4's reply over this column's, 100 reviews. 50% is a tie.", fmt: pct },
];
'''+s[j:]
s=s.replace('t.id === "tuned"','t.id === "v4"')
s=s.replace('s.id === "tuned"','s.id === "v4"')
i=s.index(' <p className="lede">'); j=s.index(' {results ? (')
s=s[:i]+''' <p className="lede">
v4 against the same model with a short prompt, the same model with a long rulebook prompt, the earlier tune, and what real owners wrote.
300 held-out reviews from businesses the model never saw in training. Half of them come with shop facts, the way the app sends them.
The rule rows are plain code, not a model. The head-to-head row is a judge model from a different family.
</p>
'''+s[j:]
i=s.index(' <section className="reading">'); j=s.index(' <p className="lede" style={{ marginTop: 22')
s=s[:i]+''' <section className="reading">
<h2>What the numbers say</h2>
<p>
The first tune (v3) copied the length of real owners but often skipped what the reviewer said, and the judge
preferred the prompted models. v4 was trained on replies that answer the review in 2 or 3 plain sentences,
with shop facts in the prompt, and it fixes that: it passes the rules about as often as a prompt that spells
every rule out, and it beats v3 head to head.
</p>
<p>
It does not beat the long-prompt model on quality. The judge calls it a tie. What it saves is the rulebook:
the same behavior from a prompt about half the size, a cheaper reply, and no instructions to keep up to date.
The rules were written from the same guide that made the training data, so the rule rows flatter it a little.
The head-to-head row and the blind test are the independent checks.
</p>
<p>
People get the last word. On the <Link href="/ab">Blind test</Link> page you see v4 against the long-prompt model
and pick the one you would send.
{votes > 0 && (
<> So far: {tally.tuned} for v4, {tally.base} for the long-prompt model, {tally.same} “about the same” ({votes} votes).</>
)}
</p>
</section>
)}
'''+s[j:]
open(p,'w').write(s)
# ---- blind test copy
sub('app/ab/page.tsx','Two replies to the same review. You are not told which one came from which model. Pick the one you would rather send.','Two replies to the same review, one from the fine-tuned model and one from the same base model with a long rulebook prompt. You are not told which is which. Pick the one you would rather send.')
sub('components/ab-test.tsx','<span>picked the fine-tuned reply</span>','<span>picked the fine-tuned reply (v4)</span>')
sub('components/ab-test.tsx','<span>picked the base model reply</span>','<span>picked the long-prompt model</span>')
E
grep -n "base model\|prompted" components/ab-test.tsx app/ab/page.tsx lib/ab-sample.ts | head; sed -i 's#^TINKER_MODEL_PATH=.*#TINKER_MODEL_PATH=tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final#' .env.local && grep -c "db1be3f5" .env.local; bun run lint 2>&1 | tail -15
app/ab/page.tsx:16: Two replies to the same review, one from the fine-tuned model and one from the same base model with a long rulebook prompt. You are not told which is which. Pick the one you would rather send.
lib/ab-sample.ts:3:// Sample pairs for offline mode. "base" mimics how a prompted base model tends to
1
$ eslint
$ bunx tsc --noEmit 2>&1 | tail -10; cat > /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/try.ts <<'E'
import { draftReply } from "/home/anish-kumar/Code/hacktoberfest/week2/replydesk/lib/model";
const shop = { name: "Hill Street Garage", category: "auto repair", ownerName: "Dan" } as any;
const facts = ["Free re-check within 7 days of any repair", "Ask for the owner at the front desk if anything went wrong"];
for (const [rating, text, f] of [[1, "Car came back with the same rattle. Wasted $300 and nobody called me back. I want my money back.", facts], [5, "Dan fixed my brakes in one afternoon and showed me the worn pads. Honest guys.", []]] as const) {
const r = await draftReply({ shop, review: { rating, text, author: "Maria Lopez" } as any, facts: f as any });
console.log(rating, "->", r.reply, `| ${r.model} ${r.latencyMs}ms $${r.costUsd.toFixed(6)}`);
}
E
bun --env-file=.env.local run /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/try.ts
1 -> I'm sorry the rattle remained and you were left without a call back. If you're still within seven days, we can do a free re-check of the repair. — Dan | Qwen3.5-4B (fine-tuned) 6586ms $0.000085
5 -> Glad to hear Dan got your brakes taken care of and showed you the worn pads. We always try to be honest and explain exactly what's wrong so you feel confident in the repair. — Dan | Qwen3.5-4B (fine-tuned) 1116ms $0.000072
$ vercel whoami 2>&1 | tail -3; vercel env ls production 2>&1 | grep -i -E "tinker|name|error" | head; git remote -v | head -2; git status --short
Vercel CLI 59.25.4 (Node.js 22.23.2)
sarcastic-soul
name value type environments created
TINKER_MODEL_PATH Hidden Secret Production 4d ago
TINKER_BASE_URL Hidden Secret Production 4d ago
TINKER_API_KEY Hidden Secret Production 4d ago
origin https://github.com/Sarcastic-Soul/replydesk.git (fetch)
origin https://github.com/Sarcastic-Soul/replydesk.git (push)
M app/ab/page.tsx
M app/better/page.tsx
M components/ab-test.tsx
M lib/data-db.ts
M lib/types.ts
M ml/replydesk_ml/prompt.py
M ml/scripts/train.py
?? ml/replydesk_ml/rules.py
?? ml/scripts/eval4.py
?? ml/scripts/teacher.py
$ vercel env rm TINKER_MODEL_PATH production --yes 2>&1 | tail -1 && printf '%s' 'tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final' | vercel env add TINKER_MODEL_PATH production --sensitive 2>&1 | tail -2 && vercel --prod --yes 2>&1 | tail -6
Permission for this action was denied by the Claude Code auto mode classifier. Reason: The server-side auto mode classifier judged this action dangerous (it gave no explanation). If you have other tasks that don't depend on this action, continue working on those. IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomplish this goal, e.g. using head instead of cat. But you *should not* attempt to work around this denial in malicious ways, e.g. do not use your ability to run tests to execute non-test actions. You should only try to work around this restriction in reasonable ways that do not attempt to bypass the intent behind this denial. If you believe this capability is essential to complete the user's request, first try a safer method. Get as much of the rest of the task done as you can, then STOP and explain to the user what you were trying to do and why you need this permission. Let the user decide how to proceed. To allow this type of action in the future, the user can add a permission rule for Bash to their settings.
$ git add -A app components lib ml/replydesk_ml ml/scripts && git -c user.email=anishisbusy@gmail.com -c user.name="Sarcastic-Soul" commit -q -m "Add v4: teacher-written training data with shop facts, rule checks, new eval and blind-test pairs" && git log --oneline | head -2; git status --short | head -3
06c7886 Add v4: teacher-written training data with shop facts, rule checks, new eval and blind-test pairs
9765bc9 Fit the blind-test progress bar and nav on phones, add inbox heading for screen readers
The app code now uses v4, but the live site still runs v3. The system blocked my Vercel production change, so I stopped there.
Done
- .env.local points at v4. A test call through lib/model.ts worked. For an angry refund review with a "Free re-check within 7 days" fact, v4 apologised and offered the re-check, with no refund promise. It took 6.6 s on that first call and 1.1 s on the second.
- /better shows the new v4 table (base, long-prompt, v3, v4, real) and honest copy. It says plainly that v4 ties the long prompt and that the rule rows flatter it a little.
- Tiger holds the new eval numbers and 40 new blind-test pairs, v4 against the long-prompt model.
- The vote count now includes only votes on the new pairs. The old 19 votes are still in the database, just not counted.
- Lint and type checks pass. I made a local commit, 06c7886, with you as the only author. I have not pushed it.
Needs you: the live app at replydesk-shop.vercel.app still uses the v3 model. To switch it I need to replace TINKER_MODEL_PATH in Vercel's production settings with the v4 path and redeploy. I can't do that unless you allow it. Either run these yourself with the ! prefix:
! cd ~/Code/hacktoberfest/week2/replydesk && vercel env rm TINKER_MODEL_PATH production --yes
! printf '%s' 'tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final' | vercel env add TINKER_MODEL_PATH production --sensitive
! vercel --prod --yes
Or tell me to go ahead and add a permission rule for these commands. I also need your OK to push the commit to GitHub.
The /better page reads Tiger directly, but its new layout only goes live with the deploy.
done, btw now 2.3 credits are left on tinker i dont assume this would ever be good enough to train new model in another challeneg week, so based on sunk cost fallacy i really wanna go hard for this track for us to make sure we win this week on tinker track
Plan before I spend: you have about $2.3, so I'll keep roughly $0.3 in reserve and use about $2 on the highest-return steps.
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && uv run python - <<'E'
import polars as pl
df=pl.read_parquet('data/pairs.parquet')
print(df.columns, df.height)
print(df.head(1).to_dicts()[0].keys())
E
['state', 'gmap_id', 'business', 'categories', 'author', 'rating', 'review', 'reply', 'review_time', 'reply_time'] 590054
dict_keys(['state', 'gmap_id', 'business', 'categories', 'author', 'rating', 'review', 'reply', 'review_time', 'reply_time'])
$ cat > scripts/build_bait.py <<'E'
"""Real 1 and 2 star reviews that ask for money, a freebie or a call-back, from businesses the model never trained on.
A reply to these is where a model is tempted to promise something the owner never offered.
The set is only used to test; nothing here goes into training.
uv run python scripts/build_bait.py # -> data/bait.jsonl
"""
import json
import random
import re
import sys
from pathlib import Path
import polars as pl
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent))
from build_dataset import CONTACT, shop_type # noqa: E402
DATA = ROOT / "data"
ASK = re.compile(
r"refund|money back|my money|compensat|reimburs|(?:want|deserve|expect|demand|asked for|ask for)[^.]{0,30}\b(?:free|discount|credit)\b"
r"|should (?:be|have been) free|owe[ds]? me|make (?:it|this) right|call(?:ed)? me back|never (?:called|heard back)|nobody called|no one called",
re.I,
)
N = 150
used = set()
for name in ("train", "val", "test"):
used |= {json.loads(line)["gmap_id"] for line in (DATA / f"{name}.jsonl").open()}
df = pl.read_parquet(DATA / "pairs.parquet").filter(
pl.col("rating") <= 2,
pl.col("review").str.len_chars().is_between(80, 600),
pl.col("review").str.count_matches(r"[A-Za-z]") / pl.col("review").str.len_chars() > 0.6,
~pl.col("gmap_id").is_in(used),
~pl.col("reply").str.contains(CONTACT.pattern),
)
rows = []
for r in df.iter_rows(named=True):
if not ASK.search(r["review"]):
continue
t = shop_type(r["categories"])
if t:
rows.append({k: r[k] for k in ("gmap_id", "business", "categories", "state", "author", "rating", "review", "reply")} | {"shop_type": t})
print(len(rows), "candidates")
random.Random(3).shuffle(rows)
seen, out = set(), []
for r in rows: # one review per business
if r["gmap_id"] not in seen:
seen.add(r["gmap_id"])
out.append(r)
out = out[:N]
with (DATA / "bait.jsonl").open("w") as f:
for r in out:
f.write(json.dumps(r) + "\n")
from collections import Counter
print(len(out), "written", dict(Counter(r["shop_type"] for r in out)), dict(Counter(r["rating"] for r in out)))
E
uv run python scripts/build_bait.py && python3 - <<'E'
import json
for l in list(open('data/bait.jsonl'))[:3]:
r=json.loads(l); print(r['rating'],r['shop_type'],r['review'][:230].replace('\n',' '),'\n')
E
325 candidates
150 written {'dentist': 5, 'salon': 8, 'retail': 50, 'auto repair': 23, 'cafe/restaurant': 55, 'home services': 9} {2: 27, 1: 123}
2 dentist Would have saved time and money if I would have drove the extra 30 minutes to our vet in Spokane, who ended up diagnosing and resolving the issue the next day. The extra star is for the partial refund.
1 salon really bad nails. hair in nails, polish on hands, very unprofessional, did not give a refund and the fix they gave was not even very good. it was a terrible experience and i will not be returning ever.
1 retail Really bad service experience. They admitted they made a mistake deactivating my insurance when I upgraded my phone. My iphone 11 max was damaged and I was shocked to find out I didn’t have insurance. They repeatedly made commitme
$ python3 - <<'E'
p='scripts/eval4.py'
s=open(p).read()
s=s.replace('''OUT = DATA / "eval" / "v4"''','''SET = os.environ.get("EVAL_SET", "test") # "bait" = real refund-demanding reviews, see build_bait.py
OUT = DATA / "eval" / ("v4" if SET == "test" else SET)''')
s=s.replace(''' rows = [json.loads(line) for line in (DATA / "test.jsonl").open()]
for i, r in enumerate(rows):''',''' rows = [json.loads(line) for line in (DATA / ("test.jsonl" if SET == "test" else f"{SET}.jsonl")).open()]
for i, r in enumerate(rows):''')
open(p,'w').write(s)
E
EVAL_SET=bait uv run python scripts/eval4.py gen 2>&1 | tail -4 && EVAL_SET=bait uv run python scripts/eval4.py report
careful: 150 replies
v4: 150 replies
base: 150 replies
v3: 150 replies
system pass_all detail no_stock no_arguing acknowledges length unbk unbk_refund_b median_words cost_per_repl prompt_tokens
base 0.05 0.90 0.77 0.77 0.99 0.13 0.49 0.57 78.00 0.0002 180.20
careful 0.49 0.91 0.97 0.81 1.00 1.00 0.31 0.36 33.00 0.0002 361.61
v3 0.21 0.61 0.99 0.90 0.91 0.80 0.43 0.45 44.00 0.0001 180.20
v4 0.69 0.92 0.99 0.89 1.00 1.00 0.14 0.18 34.50 0.0001 180.20
real 0.07 0.37 0.90 0.75 0.68 0.60 0.25 0.31 46.00 - -
refund-bait reviews: 100
$ python3 - <<'E'
p='scripts/eval4.py'
s=open(p).read()
s=s.replace('''def save(rows: list[dict]) -> None:''','''PROMISE_PROMPT = """A small shop owner is about to post this reply under a Google review.
Shop facts the owner saved (the only things the owner has offered): {facts}
Review: {review}
Reply: {reply}
Does the reply promise or offer anything the owner would have to deliver that is NOT covered by the shop facts? Count refunds, discounts, free items or services, credits, call-backs, fixes, replacements, a named staff action, or "we will" about a specific thing. Do not count apologies, thanks, or vague "we'll do better".
Answer with JSON only: {{"unbacked": true}} or {{"unbacked": false}}"""
async def promise_check(rows: list[dict]) -> None:
"""A second opinion on promises, from a model instead of the regex."""
g = gens(rows)
path = OUT / "promise.jsonl"
done = {(j["i"], j["s"]) for j in load(path.name)}
sem = asyncio.Semaphore(6)
async def one(i, s):
row = rows[i]
prompt = PROMISE_PROMPT.format(facts="; ".join(row.get("facts", [])) or "none", review=row["review"], reply=g[s][i]["reply"])
for _ in range(3):
r, _ = await call(sem, JUDGE, [{"role": "user", "content": prompt}], max_tokens=800, temperature=0,
extra_body={"reasoning_effort": "low"})
m = re.search(r'"unbacked"\\s*:\\s*(true|false)', r.choices[0].message.content or "")
if m:
return {"i": i, "s": s, "unbacked": m.group(1) == "true"}
return None
todo = [(i, s) for i in range(len(rows)) for s in SYSTEMS if (i, s) not in done and i in g[s]]
with path.open("a") as f:
for coro in asyncio.as_completed([one(i, s) for i, s in todo]):
if res := await coro:
f.write(json.dumps(res) + "\\n")
f.flush()
js = load(path.name)
for s in SYSTEMS:
w = [j["unbacked"] for j in js if j["s"] == s]
facts_w = [j["unbacked"] for j in js if j["s"] == s and rows[j["i"]].get("facts")]
print(f"{s:8} model-checked unbacked promise: {sum(w)}/{len(w)} = {sum(w) / max(1, len(w)):.0%} (rows with facts: {sum(facts_w)}/{len(facts_w)})")
def save(rows: list[dict]) -> None:''')
s=s.replace(''' if step == "judge":
await judge(rows)''',''' if step == "judge":
await judge(rows)
if step == "promise":
await promise_check(rows)''')
open(p,'w').write(s)
E
EVAL_SET=bait uv run python scripts/eval4.py promise 2>&1 | tail -8
Command was manually backgrounded by user with ID: b1gv29j5p. Output is being written to: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/b1gv29j5p.output.
This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.
Summary:
1. Primary Request and Intent:
- The user (Anish, @anishisbusy on dev.to, GitHub Sarcastic-Soul) won week 1 of the Hacktoberfest Open-Source AI Challenge (Tiger Data track, "Nice Out", $100 plus a DEV badge).
- They now want to maximize their chance of winning the Best Use of Tinker track ($200) for week 2 ("Side Hustle") with ReplyDesk, even if that uses all remaining Tinker credit. Their words: "i really wanna go hard for this track for us to make sure we win this week on tinker track". Credit left is about $2.3. They accept not using Tinker in later weeks.
- Beyond the model, they asked to find what else to improve in the project. The submission is already published (Oct 16) and can be edited until Mon Oct 19, 12:29 PM IST.
- They also asked to scrape competitors (Tinker entries), judge them, count track popularity, and update the app with the v4 model.
Key Technical Concepts:
tinker-cookbook, an OpenAI-compatible sampling endpoint, and checkpoint storage at $0.10/GB-month. Prices per 1M tokens: Qwen3.5-4B prefill $0.33, sample $1.005, train $0.737. gpt-oss-120b sample $0.84. Qwen3.6-35B-A3B sample $1.335. Qwen3.8-27B sample $5.595. reasoning_effort: false (boolean) disables thinking for Qwen. gpt-oss uses "low".lib/prompt.ts already sends in the app.pg_textsearch plus DiskANN vectorscale), Backboard memory, Groq promise check, Vercel.hf26challenge, per_page 100, rate limits.Files and Code Sections:
week2/replydesk/ml/replydesk_ml/rules.py (new): shared rule checks. Key parts:
PROMISE regex: refund|discount|free (not hyphen-adjacent)|complimentary|on us|on the house|gift card|% off|compensat|call you|contact you|reach out to you|make it right, plus "issue/give/offer credit" and "we'll/I'll replace/redo".unbacked_promises(reply, facts): allowed if the key word appears in a fact string.check(review, rating, reply, facts) returns a dict with keys length, detail, no_stock, no_arguing, acknowledges, no_signoff, no_unbacked_promise. passes() is all of them. MIN_WORDS, MAX_WORDS = 15, 55.week2/replydesk/ml/replydesk_ml/prompt.py: added system_prompt(row), which appends "\n\nShop facts (only promise what is listed here):\n- ..." when row["facts"] is set. messages() now uses it.week2/replydesk/ml/scripts/teacher.py (new): TEACHERS dict, FACTS bank per shop type, STYLE guide, pick_rows, style_prompt (3 real examples of the same type and rating bucket), clean (normalizes curly quotes and em dashes), generate(teachers,...) (comma list, candidate k uses teacher k mod len), recheck, summarise, build (opening-phrase cap of 12 per 3-word opening, adds real replies up to half the teacher count, writes data/v4/train_chat.jsonl). Commands: pilot <teacher[,teacher]> N, gen <teachers> N, build. CANDIDATES = 2.week2/replydesk/ml/scripts/train.py: added env options TRAIN_FILE, EPOCHS, SAVE_EVERY, with the old defaults kept. v4 was trained with TRAIN_FILE=v4/train_chat.jsonl EPOCHS=2 SAVE_EVERY=20.week2/replydesk/ml/scripts/eval4.py (new): systems base, careful, v3, v4, real. TUNED = {"v3": "tinker://1cf358f7-dec5-50e7-b3fd-57db9f467d70:train:0/sampler_weights/final", "v4": "tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final"}. Steps are gen, report, judge (v4 vs base, careful, v3, real, 100 reviews), save (writes eval_results plus 40 v4-{i} blind pairs of careful vs v4 into Tiger). EVAL_SET env: default test (data/test.jsonl, out data/eval/v4), or bait (data/bait.jsonl, out data/eval/bait). Facts are randomly assigned to about 50% of rows per random.Random(f"eval-{i}").week2/replydesk/ml/scripts/build_bait.py (new): builds data/bait.jsonl with 150 real 1–2 star reviews that ask for money, a freebie or a call-back. They come from businesses outside train/val/test and are used for testing only. Distribution: retail 50, cafe/restaurant 55, auto repair 23, home services 9, salon 8, dentist 5.06c7886, but not pushed: lib/types.ts (EvalSystem = base|careful|v3|v4|real, new EvalMetric union), lib/data-db.ts (imports like, tally filters like(abVotes.pairId, "v4-%")), app/better/page.tsx (new SYSTEMS and METRICS and honest copy), app/ab/page.tsx (new lede), components/ab-test.tsx (labels "picked the fine-tuned reply (v4)" and "picked the long-prompt model")..env.local (not committed): TINKER_MODEL_PATH set to the v4 path. The user says Vercel production was updated and redeployed ("done").week2/CONTEST.md, PLAN.md, POST.md, DATASETS.md: read for context. The post is at week2/POST.md and the live post is https://dev.to/anishisbusy/replydesk-a-small-tuned-model-for-google-review-replies-and-the-test-it-lost-4ab6./tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/devto/ (all.json, body/*.json, tinker2.json).Errors and fixes:
KeyError: 'user' and JSON decode errors when parsing bodies: fixed with try/except and .get.teacher.py summarise raised KeyError: 'i' because it indexed rows by a missing field. Fixed with dict(enumerate(load_rows())).NameError: teacher after the multi-teacher refactor. Fixed by printing teachers.PROMISE regex to be forward-looking.eval4.py report crashed formatting None for the real system's cost. Fixed with a None check.!, and they did..env.local) once. It is only in terminal output; avoid repeating.Problem Solving:
All user messages:
Pending Tasks:
/better (a bait column or section), and update README.md./ab, eval chart, Tiger search), the demo video embedded near the top, and the missing "My Agent Session" section (which also enters the Entire track)./ab pairs).06c7886 to GitHub (not yet pushed).week2/POST.md updates in plain English (user publishes).Current Work:
Immediately before the summary request I had built the bait eval and run it. I wrote ml/scripts/build_bait.py (150 real refund-demanding reviews, data/bait.jsonl), added the EVAL_SET env to eval4.py, and ran EVAL_SET=bait uv run python scripts/eval4.py gen and report. The output was:
system pass_all detail no_stock no_arguing acknowledges length unbk unbk_refund_b median_words cost_per_reply prompt_tokens
base 0.05 0.90 0.77 0.77 0.99 0.13 0.49 0.57 78.00 0.0002 180.20
careful 0.49 0.91 0.97 0.81 1.00 1.00 0.31 0.36 33.00 0.0002 361.61
v3 0.21 0.61 0.99 0.90 0.91 0.80 0.43 0.45 44.00 0.0001 180.20
v4 0.69 0.92 0.99 0.89 1.00 1.00 0.14 0.18 34.50 0.0001 180.20
real 0.07 0.37 0.90 0.75 0.68 0.60 0.25 0.31 46.00 - -
refund-bait reviews: 100
I had not yet interpreted the results to the user, run the judge on the bait set, or saved them anywhere. The user is waiting for a response to their message about going hard for the Tinker track with the $2.3 left.
If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: [REDACTED]/.claude-work/projects/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.
The messages after this summary are the most recent messages from before compaction, kept verbatim. The summary was written without seeing them, so something it says has not happened yet may already have happened in them.
$ sleep 45; cat /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/b1gv29j5p.output; wc -l /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/data/eval/bait/promise.jsonl
<tool_use_error>Blocked: sleep 45 followed by: cat /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/b1gv29j5p.output wc -l /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/data/eval/bait/promise.jsonl. To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.</tool_use_error>
$ cat /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/tasks/b1gv29j5p.output
base model-checked unbacked promise: 54/150 = 36% (rows with facts: 25/77)
careful model-checked unbacked promise: 44/150 = 29% (rows with facts: 11/77)
v3 model-checked unbacked promise: 65/150 = 43% (rows with facts: 36/77)
v4 model-checked unbacked promise: 23/150 = 15% (rows with facts: 7/77)
real model-checked unbacked promise: 38/150 = 25% (rows with facts: 17/77)
[exited with code 0]
$ EVAL_SET=bait uv run python scripts/eval4.py judge 2>&1 | tail -12
v4 vs base: 63/100 = 63%
v4 vs careful: 79/100 = 79%
v4 vs v3: 84/100 = 84%
v4 vs real: 82/100 = 82%
Bait set results are in. v4 wins clearly on hard reviews, and two independent checks agree.
150 real refund-demanding reviews from shops never in training:
| base | careful (long prompt) | v3 | v4 | real owners | |
|---|---|---|---|---|---|
| Rule pass rate | 5% | 49% | 21% | 69% | 7% |
| Promises beyond the facts (regex) | 49% | 31% | 43% | 14% | 25% |
| Promises beyond the facts (model check) | 36% | 29% | 43% | 15% | 25% |
The model check is a second opinion from gpt-oss-120b, so the result doesn't rest only on my regex.
Pairwise judge (100 pairs):
- v4 beats careful 79%, v3 84% and real owners 82%.
- v4 beats base 63%.
- On the normal test set v4 only tied careful (55%). On bait it wins by a wide margin. That is the story for the post: the tuned 4B model holds the line on refunds better than a prompt that is twice as long.
Caveats to state in the post:
- The judge is one model, so it can be biased.
- Rule and judge scores are not human votes.
- n=100 gives about ±10.
v5: I recommend skipping it. v4 already wins where it matters. A retrain would reset the /ab votes and risk a regression, and the remaining $2.3 buys more elsewhere. I'd keep roughly $1.5 for live demo traffic and the judge runs.
Next, unless you object:
1. Save the bait results to Tiger and add a bait section to /better, then update the README.
2. Rewrite week2/POST.md around the v4 and bait numbers, and add the missing "My Agent Session" section. You publish it yourself.
3. Check the public demo is rate-limited so strangers can't drain the Tinker credit.
Say if you'd rather I retrain a v5 anyway.
yeah sounds good, i think we can publish v4 as our final submission, now before we wrap up editing and writing the dev.to post i thinkl we should know what is the judging criteria is wfor winnign https://dev.to/challenges/hacktoberfest-week2-2026-10-12 and i think writing a good post is most important, so we need to add images, embeed video, show stats and stuff. should we also show screenshots of the tinker dashboard? [Image #3] [Image #4] [Image #5]. last thing is just for backup we should also make our other partner usage strong too cause just incase we do not win in tinker track maybe we win in otehr 2 track
[Image: source: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/images/3.png]
[Image: source: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/images/4.png]
[Image: source: /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/images/5.png]
{
"query": "select:WebFetch",
"max_results": 1
}
/home/anish-kumar/Code/hacktoberfest/week2/CONTEST.md
1 # Hacktoberfest Week 2 DEV Challenge — Contest Details
2
3 **Challenge:** Hacktoberfest Open-Source AI Challenge: Week 2 (challenge 3 of 5)
4 **Page:** https://dev.to/challenges/hacktoberfest-week2-2026-10-12
5 **Hub:** https://dev.to/challenges/hf26
6 **Contest rules:** https://dev.to/page/hacktoberfest-week2-2026-10-12-contest-rules
7 **General rules (MLH):** https://dev.to/page/official-hackathon-rules
8 **Sponsor:** Major League Hacking PBC Inc (MLH)
9
10 Info collected Oct 13, 2026 from the pages above.
11
12 ---
13
14 ## 1. Key dates
15
16 | What | When (page) | When (IST) |
17 |---|---|---|
18 | Contest opens | Oct 12, 2026 | Mon Oct 12, evening |
19 | **Submissions due** | **Oct 18, 2026, 11:59 PM PDT** | **Mon Oct 19, 12:29 PM** |
20 | Winners announced | Week of Oct 19 | |
21 | Prize email | Within 10 business days of the announcement | |
22
23 The other challenges: Weekend (Oct 2–5, "Build for a Friend"), Week 1 (Oct 5–11, "Touch Grass"), Week 3 (Oct 19–25), Week 4 (Oct 26–31). Each theme is kept secret until launch day.
24
25 ---
26
27 ## 2. Prompt and theme
28
29 **Prompt (same for all five challenges):** Build something with open-source AI at its core. That can mean running an open-weight model, building on an open-source agent harness or framework, running inference locally, or all three. The open pieces should be what makes the project work.
30
31 In the post, explain **why open innovation matters for what you built**. Does it run on a laptop with no internet? Keep someone's data off a server they don't control? Let you fine-tune, swap models, or change how the agent behaves? Cost nothing to run? Say where the open approach worked better than a closed one.
32
33 **Theme: Side Hustle.** Build something with open-weight models or open-source AI that someone would actually pay for. Think small, specific and useful. The best builds solve a real problem for a real person: a freelancer, a small business owner, a niche hobby community, or a friend who keeps complaining about the same boring task.
34
35 Example ideas from the page (avoid these, everyone will copy them):
36 - An invoice and receipt sorter for freelancers that runs locally, so client data never leaves their laptop
37 - A listing writer for Etsy or eBay sellers that turns a few product photos into a polished description
38 - A menu translator for a local restaurant that handles their specific dishes better than a generic tool
39
40 Questions the post should answer: Does running your own model keep costs low enough that the business works? Keep customers' data private? Let you fine-tune for a niche that a general API handles poorly? Avoid lock-in to someone else's pricing?
41
42 **Bonus points: land your first user.** Get it in front of someone, let them use it, and tell the story. Did they pay? Would they? What did they ask for next?
43
44 ---
45
46 ## 3. Judging
47
48 1. **Writing quality** (weighted most heavily). "A great project with a thin write-up won't win."
49 2. Relevance to the prompt and theme
50 3. Creativity
51 4. Technical execution
52 5. Use of partner technology (only if entering a partner category)
53
54 **Ties:** if judges tie, the entry with the most positive reactions on its DEV post wins.
55
56 ---
57
58 ## 4. Prizes
59
60 All prizes are **cash in USD**, not platform credits.
61
62 | Prize | Count | Reward |
63 |---|---|---|
64 | Overall winner | 1 | $250 USD + DEV++ membership + exclusive DEV badge |
65 | Featured category winner | 6 | $200 USD + exclusive DEV badge |
66 | Partner category winner | 11 | $100 USD + exclusive DEV badge |
67 | Every valid entry | all | Completion badge on DEV profile |
68
69 **Each participant can win at most one prize in this contest.** Entering more categories only gives more chances to win one prize. The prizes don't add up.
70
71 Partner credits (Tinker, Render, Backboard, free .tech domain) are separate free perks for building, claimed at https://hacktoberfest.com/my/promos. They are not prizes.
72
73 ### Claiming a cash prize
74
75 - DEV emails the address on your DEV profile within 10 business days of the announcement.
76 - You may need to sign an affidavit of eligibility and publicity/liability release, and give tax info (W-9 or equivalent), **within 7 business days** of the first email. Missing this can forfeit the prize.
77 - Prizes are delivered 2–4 weeks after the paperwork is accepted.
78 - The rules don't say how the money is paid (bank, PayPal, etc.).
79 - Winners pay any taxes on prizes.
80 - Team prizes go to the team. DEV doesn't split them.
81
82 ---
83
84 ## 5. Prize categories (17)
85
86 To enter a category, the project must **meaningfully use** that technology and the post must **name the category** in the "Prize Categories" section. One project can enter every category it really uses.
87
88 "Week 1 entries" = week 1 posts that listed that category in their Prize Categories section (from my count on Oct 13: 1,689 week 1 posts read). "Weekend" = the same count for the weekend challenge (from week1/PLAN.md).
89
90 ### Featured categories ($200)
91
92 | Category | What counts | Week 1 entries | Weekend | Usable for us? |
93 |---|---|---|---|---|
94 | Render | Use Render as the AI runtime, to host an agent's front end, or to run Hermes or OpenClaw | 226 | 172 | Crowded |
95 | TabPFN | Use TabPFN (Prior Labs) to forecast, predict, classify or spot anomalies from historical data like a CSV. TabPFN-3.5 recommended (free non-commercial license at platform.priorlabs.ai). Prior Labs favors TabPFN inside a wider prediction agent | 64 | 34 | Yes, used it in week 1 |
96 | Tinker | Use Thinking Machines' Tinker to fine-tune a model for a task and show a clear gain in performance, latency or cost over a baseline | 27 | 28 | Possible, needs a real fine-tune + before/after |
97 | Arduino | Build with an Arduino UNO Q: run a model on the board, build a physical agent, or optimize a model with Qualcomm AI Hub | 9 | 7 | No board |
98 | DigitalOcean | Host the app or agent on DigitalOcean, run an open-weight model on a GPU Droplet, or build an agent on the Gradient AI Platform | 17 | 16 | No: needs a card or PayPal |
99 | Gemma | Use Gemma (Google's open-weight model): run it locally, fine-tune it, or serve it through Google Cloud or another provider | 575 | 441 | Very crowded |
100
101 ### Partner categories ($100)
102
103 | Category | What counts | Week 1 entries | Weekend | Usable for us? |
104 |---|---|---|---|---|
105 | Backboard | Build with R-CLI (Backboard's open-source terminal coding agent), compare open-weight models through one Backboard API key, or give an open-source project's assistant memory and RAG | 23 | — | Possible |
106 | ElevenLabs | Give an open-source agent a voice, transcribe audio for a local model, or generate narration for the demo | 106 | — | Crowded |
107 | Entire | Share the agent sessions behind the project in the write-up, or search past agent work to explain why code exists | 38 | 27 | Possible |
108 | GitHub Copilot | Build with Copilot's coding agent, Copilot CLI or the Copilot app, automate with GitHub Actions, or review PRs with Copilot | 65 | — | Medium |
109 | Mastra | Orchestrate an agent over open models, add memory and tools to a chat agent, or build a workflow that triages GitHub issues | 30 | 23 | Yes, used it in week 1 |
110 | MongoDB Atlas | Atlas Vector Search for retrieval, long-term memory for an agent, or the database behind an app on an open-weight model | 54 | — | Medium |
111 | Sentry Agent Tracing | Show the agent's setup and performance (latency, tokens, cost), what you found and how you debugged it. Include traces or screenshots | 43 | — | Possible |
112 | SerpApi | Give an agent live web search, ground a RAG app in fresh results, or track news for an open-source project | 52 | — | Medium |
113 | **.tech Domain** | Put the project live on a .tech domain claimed through Hacktoberfest and link it in the post. Free first year at hacktoberfest.com/my/promos (renews at $29.99/year) | **2** | — | **Yes, easy add-on** (check the claim needs no card) |
114 | Temporal | Wrap the agent in a Temporal workflow so it survives failures, retries flaky tool calls and resumes where it left off | 20 | 20 | Needs a place to run a worker |
115 | **Tiger Data** | Store embeddings with pgvector, run hybrid keyword and vector search for an agent, or let an agent manage a database through Tiger MCP | **17** | 11 | **Yes:** free plan, account exists, used in week 1 |
116
117 Partner categories can change between challenges. This list is the one on the week 2 page.
118
119 ---
120
121 ## 6. Rules that matter
122
123 - **New project only.** All code and the repo must be started and finished inside the Oct 12–18 window. No reusing the week 1 project (Nice Out). Pull requests to existing projects don't count.
124 - **Commits after the deadline** must be noted in the README, or the entry can be disqualified.
125 - **One submission per challenge** per person (or per team).
126 - **Teams:** up to 4 people. List teammates' DEV usernames in the post. Publish once per team.
127 - **Open source code:** borrowing and improving is fine, but changes must be big enough, and any non-trivial prior work must be credited. Plagiarism means disqualification or a DEV ban.
128 - **English only** for prizes. Non-English posts only get the completion badge.
129 - **AI tools are allowed.**
130 - **Age 18+.**
131 - **Eligibility:** open to everyone except residents of Afghanistan, Belarus, Central African Republic, Cuba, Equatorial Guinea, Iran, Iraq, Kosovo, Libya, Myanmar, North Korea, Russia, South Sudan, Sudan, Syria, Tanzania, Venezuela, Yemen, and US-sanctioned places. **India is eligible.**
132 - **Required tags:** `#devchallenge` and `#hf26challenge` (the template adds them).
133 - **Agent session:** optional but judges like it. Save it with DevRelay and embed it with the `agent_session` tag, or link it.
134
135 ---
136
137 ## 7. Submission template
138
139 Use this exact structure (from the "Write a submission" button):
140
141 ```markdown
142 ---
143 title:
144 published:
145 tags: devchallenge, hf26challenge
146 ---
147
148 *This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle](https://dev.to/challenges/hacktoberfest-week2-2026-10-12)*
149
150 ## What I Built
151 <!-- What does it do, and who would pay for it? What problem does it solve for them? -->
152
153 ## Demo
154 <!-- Share a deployed link or a video demo. -->
155
156 ## Code
157 <!-- Show us the code! You can embed a GitHub repo directly into your post. -->
158
159 ## How I Built It
160 <!-- Which open-source AI did you use (open-weight models, agent harnesses, frameworks, local inference), and how is your project built around it? -->
161
162 ## Why Does Open Innovation Matter?
163 <!-- Why does open innovation matter for what you built? What did it make possible that a closed API wouldn't? -->
164
165 ## My Agent Session
166 <!-- Optional, but judges love it. Save your session with DevRelay and embed it with the agent_session tag (see the challenge page), or link to it. -->
167
168 ## My First User
169 <!-- Optional, but this is where bonus points live. Did someone use it? Who were they, what did they say, and did they pay? If not, remove this section. -->
170
171 ## Prize Categories
172 <!-- Which partner categories are you entering? List every one that applies, or remove this section. -->
173
174 <!-- Team Submissions: Please pick one member to publish the submission and credit teammates by listing their DEV usernames directly in the body of the post. -->
175
176 <!-- Thanks for participating! -->
177 ```
178
179 New compared to week 1: the **"My First User"** section.
180
181 ---
182
183 ## 8. How many people took part so far
184
185 Counted on Oct 13 from the dev.to API: every post tagged `#hf26challenge` (about 3,450 posts), with full post bodies read for week 1.
186
187 | Challenge | Posts | Notes |
188 |---|---|---|
189 | Weekend (Build for a Friend) | ~1,200 | |
190 | Week 1 (Touch Grass) | **~1,690** (1,642 authors) | Posts that state "Week 1: Touch Grass". About 300 more posts skip that line, roughly 130 of them during week 1, so the real total is likely 1,750–1,800 |
191 | Week 2 (Side Hustle) | ~40 | After one day |
192
193 In week 1, about 575 posts had no Prize Categories section at all.
194
195 **Our week 1 entry:** "Nice Out: an app that learns your kind of nice weather, then tells you to go outside" (https://dev.to/anishisbusy, published Oct 6). Entered TabPFN (64 entries), Tiger Data (17) and Mastra (30). 6 reactions, 0 comments as of Oct 13.
196
197 ---
198
199 ## 9. Our constraints (carried over from week 1)
200
201 - Everything free, with **no card or payment method** on any service. This rules out DigitalOcean.
202 - **No local LLM.** The laptop has an i5-1235U, 7.5 GiB RAM and no GPU. The LLM runs on a free hosted API that serves open-weight models.
203
204 ## 10. Target categories (draft)
205
206 Low-competition categories we can actually use:
207
208 1. **Tiger Data** ($100, 17 week 1 entries): pgvector + hybrid search at the core of the app.
209 2. **Tinker** ($200, 27 entries): main track. $10 credit redeemed, no card. Fine-tune a small open model for the side hustle's task and show it beats the base model.
210 3. **Backboard** ($100, 23): $5 credit redeemed, no card.
211 4. **Mastra** ($100, 30) if the app has an agent.
212
213 Dropped: .tech Domain (needs a card), DigitalOcean (needs a card), Arduino (needs an UNO Q; we only have an UNO R3 clone).
214
215 Since only one prize can be won, go deep on one category and treat the rest as cheap add-ons.
216
217 ---
218
219 ## 11. Partner promos and service access
220
221 Promos from https://hacktoberfest.com/my/promos (one per person, checked Oct 13):
222
223 | Promo | Amount | Card needed? | How to claim | Notes |
224 |---|---|---|---|---|
225 | .tech domain | Free first year | **Yes** (found Oct 13) | Redeem the code at get.tech | **Dropped:** needs card details. Code expires Dec 30, 2026 |
226 | Backboard | $5 credit | **No** (stated) | Sign up, enter the code on the Billing page | **Redeemed Oct 13.** The free plan also has its own $5 credit |
227 | Render | $50 credit | Unclear (support says credits are used "instead of the card on file") | Billing → Credit Balance | **Code HTHON-DB322A fails with "invalid code".** Support ticket open since Oct 8, only the AI bot has replied. Claim by Nov 15, 2026 |
228 | Tinker | $10 credit | **No** | Use the promo code | **Account made, redeemed Oct 13.** Enough for a small fine-tune (see below) |
229
230 Services with no promo:
231
232 | Service | Free? | Card? | Notes |
233 |---|---|---|---|
234 | Mastra | Yes, open-source npm framework | No account needed | Used in week 1 |
235 | Temporal | Yes when self-hosted (`temporal server start-dev`) | No for self-hosted. Temporal Cloud has a $1,000 sign-up credit; card need unclear | A live demo needs the server and a worker running all the time somewhere |
236 | Tiger Data | Free plan | No | Account already exists |
237
238 **Tinker cost check:** cheapest training rates per million tokens are GPT-OSS-20B $0.396, Qwen3-8B $0.44 (sampling $0.45–0.60). $10 covers roughly 15–20M training tokens, far more than a small LoRA fine-tune on a few thousand examples needs. Leave room for baseline vs fine-tuned eval runs.
239
240 ---
241
242 ## 12. Links
243
244 - Challenge page: https://dev.to/challenges/hacktoberfest-week2-2026-10-12
245 - All entries: https://dev.to/t/hf26challenge/latest
246 - Partner credits: https://hacktoberfest.com/my/promos
247 - Questions: comments on the week 2 launch post, or the MLH Discord
248 - Week 1 plan (for reference): ../week1/PLAN.md
249
{"url":"https://dev.to/challenges/hacktoberfest-week2-2026-10-12","prompt":"List exactly: the judging criteria and their order/weights, any tips on what judges like (images, video, agent session, firs...
**Judging criteria (in order):**
1. Writing Quality, "weighted most heavily"
2. Relevance to the Prompt and Theme
3. Creativity
4. Technical Execution
5. Use of Partner Technology (optional)
Only Writing Quality has a stated weight. The page doesn't give weights for the others.
**Tips for what judges like:**
- **Agent session:** Save it with DevRelay and embed it or link to it. The page calls this optional but says it "helps the judges understand your process."
- **Demo:** The template asks for "a deployed link or a video demo."
- **Code:** The template says "Show us the code!" and suggests embedding a GitHub repo.
- **First user:** Bonus points for getting the project in front of someone. The page asks whether they paid, would pay, and what they asked for next.
- **Open innovation:** Explain why open pieces matter, such as running offline, keeping data private, cost, or fine-tuning.
**Post template:**
The template's title and tags are `devchallenge` and `hf26challenge`. Its section headings are:
- "What I Built"
- "Demo"
- "Code"
- "How I Built It"
- "Why Does Open Innovation Matter?"
- "My Agent Session" (optional)
- "My First User" (optional)
- "Prize Categories" (optional)
**Partner category descriptions:**
- **Tinker, Tiger Data, Backboard, Entire, Sentry:** The provided content doesn't include category descriptions for these partners. It cuts off before them, so I can't quote them. The page only names Tinker and Backboard as partners giving credits.
- **Render (for reference):** "Use Render as your project's AI runtime, to host an agent's front end, or to run Hermes or OpenClaw." Prize: $200 USD plus an exclusive winner badge.
- **TabPFN (for reference):** The description is "Use TabPFN, Prior Labs' tabular foundation model, to forecast, predict, classify, or spot anomalies from historical data like a CSV." Prize: $200 USD plus an exclusive winner badge.
For the five partners you asked about, check the full Hacktoberfest challenge page or the HF26 DEV Challenge Hub.
[WebFetch note: this page's text is 284737 characters long and the answer above covers only characters 0 to 100000; the final 184737 were not read — to read on, call WebFetch again with the same url and offset: 100000.]
/home/anish-kumar/Code/hacktoberfest/week2/POST.md
1 ---
2 title: "ReplyDesk: a small tuned model for Google review replies, and the test it lost"
3 published: false
4 tags: devchallenge, hf26challenge, ai, opensource
5 ---
6
7 *This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle](https://dev.to/challenges/hacktoberfest-week2-2026-10-12)*
8
9 ## What I Built
10
11 ReplyDesk writes replies to Google reviews for small shops: garages, salons, dentists, cafes, plumbers, small stores.
12
13 Every shop owner I know has the same pile of unanswered reviews. Replying matters, because people read the replies before they pick a shop, but writing a good one takes ten minutes, and the replies a chat assistant writes all sound the same: "Thank you for taking the time to share your valuable feedback, we strive to..." Customers can tell.
14
15 So ReplyDesk does one thing. You get an inbox of reviews. Click one and you get:
16
17 - a draft reply written in an owner's voice by a small open model I fine-tuned on real owner replies
18 - how you, and owners like you, answered similar reviews before
19 - a check that flags any promise in the draft that your shop facts don't back up ("we'll refund you", "we'll call you tomorrow")
20
21 You edit it, copy it, and post it yourself in Google Business Profile. It never posts on its own.
22
23 **Who pays and why it could work as a side hustle:** one reply costs about **$0.0001** on the fine-tuned model. A shop with 60 reviews a month costs less than a cent to serve, so even $3 to $5 a month per shop would be nearly all margin. That only works because the model is small and open. On a large closed model the per-reply cost is many times higher, and the price is someone else's to change.
24
25 **The fair question: isn't this just pasting a review into ChatGPT?** Partly, yes. A chat assistant with a good prompt writes a perfectly fine reply, and in my tests people often liked it more (see the results below). What ReplyDesk adds around the model is the shop context: your facts, your past replies, and a check for promises you can't keep. I don't think the model alone is worth paying for. The workflow around it might be, and I haven't proved that.
26
27 ## Demo
28
29 Live: **https://replydesk-shop.vercel.app** (the demo shop right now is my first user's cafe)
30
31 {% embed https://youtu.be/qZg3NvmLziI %}
32
33 
34
35 Pages:
36
37 - **Inbox**: reviews, the draft, the promise check, similar replies
38 - **Setup**: shop type, facts, past replies
39 - **Is it better?**: the full eval, read live from the database
40 - **Blind test**: two replies to one review, you don't know which model wrote which, pick the one you'd send
41
42 ## Code
43
44 {% embed https://github.com/Sarcastic-Soul/replydesk %}
45
46 ## How I Built It
47
48 ### The data
49
50 The [Google Local Reviews](https://mcauleylab.ucsd.edu/public_datasets/gdrive/googlelocal/) dataset from the McAuley lab at UCSD has reviews together with the owner's reply. I took four states (Wyoming, Vermont, Montana, Idaho), which gave me 590,054 review and reply pairs.
51
52 Most owner replies are not worth learning from. Lots of them are the same pasted text under every review, some are machine-translated, some include phone numbers, and some are from chains with a social media team. My filter (`build_dataset.py`) kept a pair only if the reply was in English, came within 60 days, had no contact details, was from a non-chain business in one of six shop types, wasn't a template, and actually shared a word with the review. That left 50,954 pairs.
53
54 I split train and test **by business**, so the test reviews come from shops the model never saw.
55
56 ### Fine-tuning on Tinker (three tries)
57
58 Model: **Qwen3.5-4B**, LoRA rank 32, using the `tinker-cookbook` supervised recipe, loss on the reply only, thinking turned off.
59
60 **v1** trained on everything that passed the filter. It learned to write like real owners, including the bad ones. One test reply opened with "Cameron, you are obviously confused. We did not see you in the store." Real owners write that. My users shouldn't.
61
62 **v2** dropped replies that argue, call the review false, or are pure stock phrases. Now it was polite but bland: a lot of "Thanks for the review, see you next time" under reviews that described a specific problem.
63
64 **v3** kept only replies of 20+ words that talk about at least two things the reviewer said, and for 1 to 3 star reviews, own the problem in the opening lines. 6,000 training pairs, one epoch, 1.28M tokens, **about $0.95**. All three runs together cost about $4 of the $10 Tinker credit.
65
66 The app calls the fine-tuned model through Tinker's OpenAI-compatible endpoint. One gotcha worth sharing: on Qwen3.5 you turn thinking off with `extra_body={"reasoning_effort": False}` (a boolean). The strings `"none"` and `"low"` are rejected, and if you leave thinking on, a small `max_tokens` gets used up by the thinking and you get an empty reply.
67
68 ### Did it work? The eval
69
70 I wrote replies to 300 held-out reviews (half of them 1 to 3 stars) with four writers: the same 4B with a careful prompt, my fine-tuned 4B, Qwen3.8-27B with the same prompt, and the real owner's reply. A judge from a different model family (gpt-oss-120b, also on Tinker) saw all four in random order with no labels.
71
72 | | 4B prompted | **ReplyDesk (4B tuned)** | 27B prompted | Real owner |
73 |---|---|---|---|---|
74 | Median words | 60.5 | **34** | 56 | 34 |
75 | Stock phrases | 19.3% | **2.0%** | 9.7% | 7.3% |
76 | Specific promises | 18.0% | **11.3%** | 20.3% | 12.7% |
77 | Mentions a detail | 98.3% | **56.3%** | 98.3% | 55.3% |
78 | Judge's first pick | 40.3% | **5.0%** | 44.0% | 10.7% |
79 | Cost per reply | $0.00013 | **$0.00010** | | |
80
81 Here is the honest reading.
82
83 Fine-tuning did exactly what it was trained to do. The replies moved from where the prompted model sits to where real owners sit: the same length as real replies, far fewer stock phrases than even real owners, and fewer made-up promises (the thing that gets a shop in trouble).
84
85 The judge hates it. It also ranks real owners' replies near the bottom. The prompted models write longer replies that repeat a detail back, and LLM judges reward that. So I suspected the judge was measuring polish, not "would a shop owner post this". I didn't bend the eval until my model won, so the table stays as it came out.
86
87 That is why the app has a **blind test** page: two replies to one review, one from each 4B model, sides shuffled, pick the one you'd send. Votes go into the database and show on the "Is it better?" page. I gave it to a real shop owner. The result is in the next section, and it did not go my way.
88
89 ### Tiger Data: one query for "how did we answer this before?"
90
91 The Tiger Cloud free service runs Postgres 18 with `pg_textsearch` (BM25) and `vectorscale` (DiskANN). It holds the shop, reviews, sent replies, the eval results, the blind-test votes, and 11.5k example replies from the training businesses.
92
93 When you open a review, one SQL statement takes the top 20 by BM25 and the top 20 by vector distance within your shop type, and merges them with reciprocal rank fusion. Your own past replies come first. BM25 catches the exact words ("brake pads", "root canal"), vectors catch the meaning ("they kept me waiting" vs "took forever").
94
95 I ran out of Hugging Face inference credit halfway through, so the query embedding runs **in your browser** (`@huggingface/transformers` with `bge-small-en-v1.5` in a Web Worker). Until it has loaded, the search runs on BM25 alone. No embedding server at all.
96
97 These examples are shown next to the draft. They are not put in the prompt. The model was trained on the review alone, so the app sends exactly the training prompt.
98
99 ### Backboard: memory for shop facts
100
101 Each shop gets a Backboard assistant, and every shop fact is stored as a memory. When you open a review, memory search returns only the facts that match it. For a garage, a review about a car that took too long pulls "Same-day WhatsApp update when a job is delayed", not "Ask for Ramesh at the front desk". If Backboard is down, the app falls back to all facts.
102
103 ### The promise check
104
105 Groq (`gpt-oss-20b`) lists every promise in the draft and checks it against the shop facts. Anything unbacked flips the stamp to "Check" and shows the line. A keyword check takes over if Groq fails.
106
107 ### Stack
108
109 Next.js 16, React 19, Tailwind 4, drizzle-orm, Phosphor icons, bun, deployed on Vercel. Python with uv and polars for the data and training side.
110
111 ## Why Does Open Innovation Matter?
112
113 - **I own the model.** The weights are mine. If Tinker disappeared tomorrow, I could run a 4B model on a laptop or a cheap GPU. A shop's reply tool shouldn't break because an API changed.
114 - **The price is mine to set.** At $0.0001 a reply a $3 subscription works. On a big closed model the margin shrinks to nothing and depends on someone else's price list.
115 - **I could change what the model does, not just what I ask it.** Fine-tuning moved the replies to the owner-like length and cut the stock phrases and made-up promises. That is something a prompt on a closed API couldn't do for me, even though it didn't make people like the replies more.
116 - **Open data made it possible.** None of this works without researchers publishing half a million real owner replies.
117
118 ## My First User
119
120 My first user is Pratik, who runs HOP-In Cafe near IIIT Naya Raipur. He set up his shop in ReplyDesk (name, area, three facts about the cafe), pasted in a real review from his Google page, and sent a reply. Then he did the blind test: 20 rounds, and 19 votes were saved.
121
122 Result of the 19 saved votes:
123
124 | Pick | Votes |
125 |---|---|
126 | Prompted 4B | 15 |
127 | ReplyDesk (tuned 4B) | 4 |
128 | About the same | 0 |
129
130 So the human test agrees with the judge model. The tuned model sounds more like a real owner, but a real owner still picked the longer, more detailed prompted reply about four times out of five. It is one person and 19 votes, so I wouldn't call it a general result. It does tell me that my main bet, that matching owner style beats a good prompt, did not hold up in this test.
131
132 There is no billing in the app, so nobody paid. I'm not going to claim a price I haven't tested.
133
134 ## What I'd Do Next
135
136 - Test the prompted model plus the shop facts and promise check against the tuned one, since that is probably the real product.
137 - Pull reviews in automatically instead of copy and paste. Posting to Google needs Google's Business Profile API, which I haven't tried.
138 - Train a version that keeps the "mention what the reviewer said" habit that people seem to like.
139
140 ## Prize Categories
141
142 - **Best Use of Tinker**: fine-tuned Qwen3.5-4B (three runs, about $4), serving it through Tinker's OpenAI-compatible endpoint, and running the whole eval on Tinker (900 generated replies and 300 judge calls). The gains I can show against the baselines are cost, length and fewer stock phrases and promises. The preference tests did not favor the tuned model, and the post says so.
143 - **Best Use of Tiger Data**: hybrid BM25 + DiskANN search with RRF in one SQL statement on `pg_textsearch` and `vectorscale`, plus all app data, eval results and blind-test votes.
144 - **Best Use of Backboard**: per-shop memory for shop facts, with memory search choosing the facts for each review.
145
146 ---
147
148 Dataset credit: Google Local Reviews, McAuley lab, UCSD (Li, Shang, McAuley, *UCTopic*, ACL 2022; Yan et al., *Personalized Showcases*, SIGIR 2023).
149
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && git status --short | head; git log --oneline | head -5; ls; ls app app/api lib docs docs/shots 2>/dev/null; wc -l lib/*.ts | tail -20
M ml/scripts/eval4.py
?? ml/scripts/build_bait.py
06c7886 Add v4: teacher-written training data with shop facts, rule checks, new eval and blind-test pairs
9765bc9 Fit the blind-test progress bar and nav on phones, add inbox heading for screen readers
a4ad74d Match similar replies on the same side of the rating, drop repeated reviews
15e33ab Word the judge finding more carefully
10157a5 Use v3 model, show full eval and blind-test votes on /better, rewrite README
AGENTS.md
app
bun.lock
components
db
docs
embed-space
eslint.config.mjs
lib
LICENSE
ml
next.config.ts
next-env.d.ts
node_modules
package.json
postcss.config.mjs
README.md
scripts
tsconfig.json
tsconfig.tsbuildinfo
app:
ab
actions.ts
better
globals.css
layout.tsx
page.tsx
setup
docs:
shots
docs/shots:
ab-desktop.png
ab-end.png
ab-phone.png
after-sent.png
better-desktop.png
better-phone.png
dialog-desktop.png
inbox-desktop.png
inbox-phone-full.png
inbox-phone.png
setup-desktop.png
setup-phone.png
lib:
ab-sample.ts
ab.ts
backboard.ts
csv.ts
data-db.ts
data-mock.ts
data.ts
db
embed-worker.ts
facts.ts
format.ts
guardrail.ts
model.ts
prompt.ts
types.ts
use-embedder.ts
37 lib/ab-sample.ts
18 lib/ab.ts
57 lib/backboard.ts
58 lib/csv.ts
256 lib/data-db.ts
139 lib/data-mock.ts
19 lib/data.ts
24 lib/embed-worker.ts
18 lib/facts.ts
14 lib/format.ts
107 lib/guardrail.ts
113 lib/model.ts
34 lib/prompt.ts
98 lib/types.ts
46 lib/use-embedder.ts
1038 total
{"url":"https://dev.to/challenges/hacktoberfest-week2-2026-10-12","prompt":"Find the sections about 'agent session', DevRelay, and the Prize Categories list for Tinker, Tiger Data, Backboard, Entire, ...
The excerpt doesn't include the sections you asked for. It's mostly base64-encoded logo images and ends with "[Content truncated due to length...]" before the Tiger Data, Entire, Sentry Agent Tracing, and Backboard sections would appear. It doesn't mention "agent session," DevRelay, or how to embed an agent session.
What the excerpt does contain:
- **Best Use of Tinker:** "Use Thinking Machines' Tinker to fine-tune a model for a specific task". Prize: $200 USD plus an exclusive winner badge.
- **Best Use of Arduino, DigitalOcean, and Gemma:** Each lists a $200 USD prize plus an exclusive winner badge.
- **Partner categories:** The heading is followed by "$100 for each winner." The excerpt doesn't describe what each partner category requires. Backboard appears only as a logo.
If you paste the remainder of the page, starting at the point where this excerpt ends, I can look for the agent session, DevRelay, and the remaining partner category requirements.
[WebFetch note: this page's text is 284737 characters long and the answer above covers only characters 100000 to 200000; the final 84737 were not read — to read on, call WebFetch again with the same url and offset: 200000.]
$ cd /tmp && curl -s https://dev.to/challenges/hacktoberfest-week2-2026-10-12 -o /tmp/ch2.html; python3 -I - <<'E'
import re,html
s=open('/tmp/ch2.html').read()
s=re.sub(r'data:[^"\')]{200,}','DATA',s)
t=re.sub(r'<[^>]+>',' ',s); t=html.unescape(re.sub(r'\s+',' ',t))
for k in ['agent_session','DevRelay','Sentry','Entire','Backboard','Tiger Data']:
for m in list(re.finditer(k,t))[:3]:
print(k,'::',t[max(0,m.start()-150):m.start()+500],'\n')
E
DevRelay :: they pay? Would they? What did they ask for next? Submission Template Show your work. We'd love to see how you built it. Save your agent session with DevRelay and embed it in your post, or link to it. It's optional, but it helps the judges understand your process. Judging Criteria Writing Quality (weighted most heavily) Relevance to the Prompt and Theme Creativity Technical Execution Use of Partner Technology (optional) Prizes $250 USD + DEV++ Membership + Exclusive winner badge Prize Categories Featured categories $200 for each winner Best Use of Render Use Render as your project's AI runtime, to host an agent's front end, or to run Hermes o
DevRelay :: lves. Thank you for understanding! Should I include my agent session? Show your work. We'd love to see how you built it. Save your agent session with DevRelay and embed it in your post, or link to it. It's optional, but it helps the judges understand your process. Can my submission include open source code? Riffing on open source code and borrowing and improving on previous work/ideas is encouraged but it's important your changes are significant enough to ensure your submission is valid. What happens if my submission is considered plagiarized or invalid? Anything deemed to be plagiarism will not be eligible for prizes. Incidental plagiarism m
Sentry :: val, long-term memory for an agent, or the database behind an app built on an open-weight model. Prizes $100 USD + Exclusive winner badge Best Use of Sentry Agent Tracing Show your agent's work: how it's set up, how it performs (latency, tokens, cost), what you found, and how you debugged it. Include traces or screenshots in your write-up. Prizes $100 USD + Exclusive winner badge Best Use of SerpApi Give an agent live web search, ground a RAG app in fresh results, or track news for an open-source project. Prizes $100 USD + Exclusive winner badge Best Use of a .tech Domain Put your project live on a .tech domain you claimed through Hacktoberfe
Sentry :: berfest.com/my/promos . Partner docs: Render TabPFN Tinker Arduino DigitalOcean Gemma Backboard ElevenLabs Entire GitHub Copilot Mastra MongoDB Atlas Sentry Agent Tracing SerpApi a .tech Domain Temporal Tiger Data Frequently Asked Questions The Five Challenges Do pull requests count this year? No. This year's Hacktoberfest is not about open source pull requests. There's no PR count to hit and no repos to hunt for. Instead, you build a brand-new project with open-source AI at its core, and write about it on DEV. Here's everything you need to know about Hacktoberfest 2026. How do the Hacktoberfest challenges work? We're running five DEV Challen
Sentry :: : Best Use of Backboard Best Use of ElevenLabs Best Use of Entire Best Use of GitHub Copilot Best Use of Mastra Best Use of MongoDB Atlas Best Use of Sentry Agent Tracing Best Use of SerpApi Best Use of a .tech Domain Best Use of Temporal Best Use of Tiger Data Partner categories may change from challenge to challenge as new partners join. Each challenge page lists the categories open for that challenge. Who can enter Best Use of Arduino? Anyone building with an Arduino UNO Q, whether you got one at an in-person Fest or have your own. How do I claim partner credits? Several partners are giving participants credits and promo codes, including T
Entire :: n-source agent a voice, transcribe audio for a local model, or generate narration for your demo. Prizes $100 USD + Exclusive winner badge Best Use of Entire Share the agent sessions behind your project in the write-up, or search past agent work to explain why code exists. Prizes $100 USD + Exclusive winner badge Best Use of GitHub Copilot Build with Copilot's coding agent, the Copilot CLI, or the new Copilot app, automate your project with GitHub Actions, or review contributors' PRs with Copilot. Prizes $100 USD + Exclusive winner badge Best Use of Mastra Orchestrate an agent over open models, add memory and tools to a chat agent, or build a
Entire :: a free .tech domain. Claim yours at hacktoberfest.com/my/promos . Partner docs: Render TabPFN Tinker Arduino DigitalOcean Gemma Backboard ElevenLabs Entire GitHub Copilot Mastra MongoDB Atlas Sentry Agent Tracing SerpApi a .tech Domain Temporal Tiger Data Frequently Asked Questions The Five Challenges Do pull requests count this year? No. This year's Hacktoberfest is not about open source pull requests. There's no PR count to hit and no repos to hunt for. Instead, you build a brand-new project with open-source AI at its core, and write about it on DEV. Here's everything you need to know about Hacktoberfest 2026. How do the Hacktoberfest chal
Entire :: er Best Use of Arduino Best Use of DigitalOcean Best Use of Gemma Partner categories ($100): Best Use of Backboard Best Use of ElevenLabs Best Use of Entire Best Use of GitHub Copilot Best Use of Mastra Best Use of MongoDB Atlas Best Use of Sentry Agent Tracing Best Use of SerpApi Best Use of a .tech Domain Best Use of Temporal Best Use of Tiger Data Partner categories may change from challenge to challenge as new partners join. Each challenge page lists the categories open for that challenge. Who can enter Best Use of Arduino? Anyone building with an Arduino UNO Q, whether you got one at an in-person Fest or have your own. How do I claim par
Backboard :: ries on the HF26 DEV Challenge Hub . Free partner credits Several partners are giving participants credits and promo codes, including Tinker, Render, Backboard, and a free .tech domain. Claim yours at hacktoberfest.com/my/promos . Claim your credits → Visit hacktoberfest.com Key Dates Contest start: October 12, 2026 Submissions due: October 18, 2026 Winners announced: Week of October 19 Badge Rewards Hacktoberfest Challenge Completion Badge Hacktoberfest Challenge Winner Badge Find Out More Ask questions and share your ideas on the Hacktoberfest Open-Source AI Challenge: Week 2 Launch Post. View Launch Post Challenge Prompt Side Hustle Build
Backboard :: t, or serve it through Google Cloud or another provider. Prizes $200 USD + Exclusive winner badge Partner categories $100 for each winner Best Use of Backboard Build with R-CLI, Backboard's open-source terminal coding agent, compare open-weight models through a single Backboard API key, or give an open-source project's assistant memory and RAG. Prizes $100 USD + Exclusive winner badge Best Use of ElevenLabs Give an open-source agent a voice, transcribe audio for a local model, or generate narration for your demo. Prizes $100 USD + Exclusive winner badge Best Use of Entire Share the agent sessions behind your project in the write-up, or search
Backboard :: e Cloud or another provider. Prizes $200 USD + Exclusive winner badge Partner categories $100 for each winner Best Use of Backboard Build with R-CLI, Backboard's open-source terminal coding agent, compare open-weight models through a single Backboard API key, or give an open-source project's assistant memory and RAG. Prizes $100 USD + Exclusive winner badge Best Use of ElevenLabs Give an open-source agent a voice, transcribe audio for a local model, or generate narration for your demo. Prizes $100 USD + Exclusive winner badge Best Use of Entire Share the agent sessions behind your project in the write-up, or search past agent work to explain
Tiger Data :: oral workflow so it survives failures, retries flaky tool calls, and picks up where it left off. Prizes $100 USD + Exclusive winner badge Best Use of Tiger Data Store embeddings with pgvector, run hybrid keyword and vector search for an agent, or let an agent manage a database through Tiger MCP. Prizes $100 USD + Exclusive winner badge How To Participate Publish a post on DEV using the submission template above. Tell us what you built and who would pay for it, show us a demo, and explain why open innovation matters for what you built. If you landed your first user, tell us about it! The template adds the required tags: #devchallenge and #hf26
Tiger Data :: inker Arduino DigitalOcean Gemma Backboard ElevenLabs Entire GitHub Copilot Mastra MongoDB Atlas Sentry Agent Tracing SerpApi a .tech Domain Temporal Tiger Data Frequently Asked Questions The Five Challenges Do pull requests count this year? No. This year's Hacktoberfest is not about open source pull requests. There's no PR count to hit and no repos to hunt for. Instead, you build a brand-new project with open-source AI at its core, and write about it on DEV. Here's everything you need to know about Hacktoberfest 2026. How do the Hacktoberfest challenges work? We're running five DEV Challenges back to back through October: a Weekend Challenge
Tiger Data :: of Mastra Best Use of MongoDB Atlas Best Use of Sentry Agent Tracing Best Use of SerpApi Best Use of a .tech Domain Best Use of Temporal Best Use of Tiger Data Partner categories may change from challenge to challenge as new partners join. Each challenge page lists the categories open for that challenge. Who can enter Best Use of Arduino? Anyone building with an Arduino UNO Q, whether you got one at an in-person Fest or have your own. How do I claim partner credits? Several partners are giving participants credits and promo codes, including Tinker, Render, Backboard, and a free .tech domain. Claim yours at hacktoberfest.com/my/promos . Submi
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && cat lib/backboard.ts lib/facts.ts && sed -n 1,256p lib/data-db.ts && ls scripts db ml ml/scripts && cat package.json | head -30
import "server-only";
// Backboard memory, used through plain fetch. Only the memory endpoints are used
// (our credit covers Memory and RAG, not chat).
const BASE = "https://app.backboard.io/api";
export const backboardIsConfigured = () => Boolean(process.env.BACKBOARD_API_KEY);
async function call<T>(path: string, init: { method?: string; body?: unknown } = {}, timeoutMs = 6000): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: init.method ?? "GET",
headers: { "X-API-Key": process.env.BACKBOARD_API_KEY ?? "", "Content-Type": "application/json" },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`Backboard ${init.method ?? "GET"} ${path} returned ${res.status}`);
const text = await res.text();
return (text ? JSON.parse(text) : {}) as T;
}
export async function createAssistant(shopName: string): Promise<string> {
const r = await call<{ assistant_id?: string; id?: string }>("/assistants", {
method: "POST",
body: { name: `ReplyDesk · ${shopName}` },
});
const id = r.assistant_id ?? r.id;
if (!id) throw new Error("Backboard did not return an assistant id");
return String(id);
}
type Memory = { id: string; content: string; score?: number };
export async function listMemories(assistantId: string): Promise<Memory[]> {
const r = await call<{ memories?: Memory[] }>(`/assistants/${assistantId}/memories`);
return r.memories ?? [];
}
/** Adds new facts and deletes removed ones. Unchanged facts cost nothing. */
export async function syncFacts(assistantId: string, facts: string[]) {
const existing = await listMemories(assistantId);
const want = new Set(facts);
const have = new Set(existing.map((m) => m.content));
const toAdd = facts.filter((f, i) => !have.has(f) && facts.indexOf(f) === i);
const toDelete = existing.filter((m) => !want.has(m.content));
for (const f of toAdd) await call(`/assistants/${assistantId}/memories`, { method: "POST", body: { content: f } });
for (const m of toDelete) await call(`/assistants/${assistantId}/memories/${m.id}`, { method: "DELETE" });
return { added: toAdd.length, removed: toDelete.length };
}
/** Facts that match the review. Throws on failure so the caller can fall back. */
export async function searchFacts(assistantId: string, query: string, limit = 5): Promise<string[]> {
const r = await call<{ memories?: Memory[] }>(`/assistants/${assistantId}/memories/search`, {
method: "POST",
body: { query, limit },
}, 4000);
return (r.memories ?? []).map((m) => m.content);
}
import "server-only";
import { searchFacts } from "./backboard";
import type { Shop } from "./types";
/**
* The shop facts to use for one review. Asks Backboard memory for the facts that
* match the review. If that fails (or finds nothing), uses every saved fact.
*/
export async function factsForReview(shop: Shop, reviewText: string): Promise<string[]> {
if (!shop.backboardAssistantId || !process.env.BACKBOARD_API_KEY || !shop.facts.length) return shop.facts;
try {
const found = await searchFacts(shop.backboardAssistantId, reviewText, 5);
return found.length ? found : shop.facts;
} catch (e) {
console.error("backboard search fell back to all facts:", e instanceof Error ? e.message : e);
return shop.facts;
}
}
import { asc, desc, eq, like, sql } from "drizzle-orm";
import { getDb, schema } from "./db";
import { csvToReviews } from "./csv";
import { SAMPLE_AB_PAIRS } from "./ab-sample";
import { createAssistant, syncFacts } from "./backboard";
import { CATEGORIES, type AbChoice, type AbPair, type AbTally, type Category, EvalMetric, EvalResults, EvalSystem, Review, ReplyExample, Shop, ShopInput } from "./types";
const { shops, shopFacts, reviews, replies, replyExamples, evalResults, abVotes, abPairs } = schema;
const asCategory = (c: string): Category => (CATEGORIES.some((x) => x.value === c) ? (c as Category) : "retail");
const DEFAULT_SHOP = {
name: "My shop",
category: "retail",
city: "",
ownerName: "Owner",
};
async function shopRow() {
const db = getDb();
const [row] = await db.select().from(shops).orderBy(asc(shops.createdAt)).limit(1);
if (row) return row;
const [made] = await db.insert(shops).values(DEFAULT_SHOP).returning();
return made;
}
function toReview(
r: typeof reviews.$inferSelect,
rep?: typeof replies.$inferSelect | null,
): Review {
return {
id: r.id, shopId: r.shopId, author: r.author, rating: r.rating, text: r.text,
source: r.source as Review["source"], createdAt: r.createdAt.toISOString(),
status: r.status as Review["status"],
reply: rep ? { text: rep.text, model: rep.model, sentAt: rep.sentAt?.toISOString() ?? null } : null,
};
}
export async function listReviews(): Promise<Review[]> {
const db = getDb();
const shop = await shopRow();
const rows = await db
.select({ r: reviews, rep: replies })
.from(reviews)
.leftJoin(replies, eq(replies.reviewId, reviews.id))
.where(eq(reviews.shopId, shop.id))
.orderBy(desc(reviews.createdAt));
const seen = new Set<string>();
return rows.filter((x) => (seen.has(x.r.id) ? false : (seen.add(x.r.id), true))).map((x) => toReview(x.r, x.rep));
}
export async function getReview(id: string) {
const db = getDb();
const [row] = await db.select({ r: reviews, rep: replies }).from(reviews)
.leftJoin(replies, eq(replies.reviewId, reviews.id)).where(eq(reviews.id, id)).limit(1);
return row ? toReview(row.r, row.rep) : null;
}
export async function addReview(input: { author: string; rating: number; text: string; createdAt?: string | null }) {
const db = getDb();
const shop = await shopRow();
const [row] = await db.insert(reviews).values({
shopId: shop.id, author: input.author || "Anonymous", rating: input.rating, text: input.text,
source: "paste", ...(input.createdAt ? { createdAt: new Date(input.createdAt) } : {}),
}).returning();
return toReview(row);
}
export async function importCsv(csv: string) {
const parsed = csvToReviews(csv);
if (parsed.error) return { added: 0, skipped: 0, error: parsed.error };
if (!parsed.reviews.length) return { added: 0, skipped: parsed.skipped };
const db = getDb();
const shop = await shopRow();
await db.insert(reviews).values(
parsed.reviews.map((p) => ({
shopId: shop.id, author: p.author, rating: p.rating, text: p.text, source: "csv",
...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}),
})),
);
return { added: parsed.reviews.length, skipped: parsed.skipped };
}
export async function markSent(reviewId: string, text: string, meta: { model?: string; latencyMs?: number; costUsd?: number } = {}) {
const db = getDb();
await db.delete(replies).where(eq(replies.reviewId, reviewId));
await db.insert(replies).values({
reviewId, text, model: meta.model ?? null, latencyMs: meta.latencyMs ?? null,
costUsd: meta.costUsd ?? null, sentAt: new Date(),
});
await db.update(reviews).set({ status: "replied" }).where(eq(reviews.id, reviewId));
return getReview(reviewId);
}
export async function getShop(): Promise<Shop> {
const db = getDb();
const s = await shopRow();
const facts = await db.select().from(shopFacts).where(eq(shopFacts.shopId, s.id)).orderBy(asc(shopFacts.position));
const [{ n }] = await db.select({ n: sql<number>`count(*)::int` }).from(replyExamples).where(eq(replyExamples.shopId, s.id));
return {
id: s.id, name: s.name, category: asCategory(s.category), city: s.city, ownerName: s.ownerName,
facts: facts.map((f) => f.text), exampleCount: n, backboardAssistantId: s.backboardAssistantId,
};
}
export async function saveShop(input: ShopInput): Promise<Shop> {
const db = getDb();
const s = await shopRow();
await db.update(shops).set({ name: input.name, category: input.category, city: input.city, ownerName: input.ownerName }).where(eq(shops.id, s.id));
await db.delete(shopFacts).where(eq(shopFacts.shopId, s.id));
if (input.facts.length) {
await db.insert(shopFacts).values(input.facts.map((text, position) => ({ shopId: s.id, text, position })));
}
if (input.pastReplies.length) {
// No review text and no embedding for these. They are found by their own words in findSimilarReplies.
await db.insert(replyExamples).values(
input.pastReplies.map((replyText) => ({ shopId: s.id, category: input.category, reviewText: "", replyText })),
);
}
await syncMemory(s.id, s.backboardAssistantId, input.name, input.facts);
return getShop();
}
/** Best effort: a Backboard outage must never stop the shop from saving. */
async function syncMemory(shopId: string, assistantId: string | null, shopName: string, facts: string[]) {
if (!process.env.BACKBOARD_API_KEY) return;
try {
let id = assistantId;
if (!id) {
id = await createAssistant(shopName);
await getDb().update(shops).set({ backboardAssistantId: id }).where(eq(shops.id, shopId));
}
await syncFacts(id, facts);
} catch (e) {
console.error("backboard sync", e instanceof Error ? e.message : e);
}
}
type ExampleRow = { review_text: string; reply_text: string; rating: number | null; category: string };
const toExample = (r: ExampleRow, own: boolean): ReplyExample => ({
reviewText: r.review_text, replyText: r.reply_text, rating: r.rating, category: r.category, own,
});
/**
* Hybrid search over the curated replies of the shop's trade: BM25 (words) and
* DiskANN (meaning, only when the browser sent a query vector), top 20 of each,
* merged with reciprocal rank fusion (k = 60) in one statement.
* The shop's own past replies come first when they share words with the review.
* When the rating is known, only examples on the same side (1-3 or 4-5 stars) count.
*/
export async function findSimilarReplies(
reviewText: string,
shopType: string,
limit = 3,
embedding?: number[] | null,
rating?: number,
): Promise<ReplyExample[]> {
const db = getDb();
const band = rating ? sql`and (rating <= 3) = ${rating <= 3}` : sql``;
const s = await shopRow();
const out: ReplyExample[] = [];
// Own replies have no review text, so match on the words of the reply itself.
const words = [...new Set(reviewText.toLowerCase().match(/[a-z]{4,}/g) ?? [])].slice(0, 30);
if (words.length) {
const tsq = words.join(" | ");
const own = await db.execute(sql`
select review_text, reply_text, rating, category
from reply_examples
where shop_id = ${s.id} and to_tsvector('english', reply_text || ' ' || review_text) @@ to_tsquery('english', ${tsq})
order by ts_rank(to_tsvector('english', reply_text || ' ' || review_text), to_tsquery('english', ${tsq})) desc
limit ${Math.min(limit, 2)}`);
out.push(...(own as unknown as ExampleRow[]).map((r) => toExample(r, true)));
}
const vec = embedding?.length === 384 ? `[${embedding.join(",")}]` : null;
const vecCte = vec
? sql`, vec as (
select id, row_number() over (order by embedding <=> ${vec}::vector) as rnk
from (select id, embedding from reply_examples
where shop_id is null and category = ${shopType} and embedding is not null ${band}
order by embedding <=> ${vec}::vector limit 20) v
)`
: sql``;
const vecUnion = vec ? sql`union all select id, 1.0 / (60 + rnk) from vec` : sql``;
const rows = await db.execute(sql`
with bm as (
select id, row_number() over (order by score) as rnk
from (select id, review_text <@> to_bm25query(${reviewText}, 'reply_examples_bm25_idx') as score
from reply_examples
where shop_id is null and category = ${shopType} ${band}
order by review_text <@> to_bm25query(${reviewText}, 'reply_examples_bm25_idx')
limit 20) b
)${vecCte}
, fused as (
select id, sum(rrf) as score from (
select id, 1.0 / (60 + rnk) as rrf from bm
${vecUnion}
) u group by id
)
select e.review_text, e.reply_text, e.rating, e.category
from fused f join reply_examples e on e.id = f.id
order by f.score desc
limit ${limit * 2}`);
// The pool has a few reviews posted twice; show each one once.
const seen = new Set(out.map((e) => e.reviewText));
for (const r of rows as unknown as ExampleRow[]) {
if (seen.has(r.review_text)) continue;
seen.add(r.review_text);
out.push(toExample(r, false));
}
return out.slice(0, limit);
}
export async function getEvalResults(): Promise<EvalResults | null> {
const db = getDb();
const [latest] = await db.select().from(evalResults).orderBy(desc(evalResults.createdAt)).limit(1);
if (!latest) return null;
const rows = await db.select().from(evalResults).where(eq(evalResults.runId, latest.runId));
const values: EvalResults["values"] = {};
for (const r of rows) {
const sys = r.system as EvalSystem;
(values[sys] ??= {})[r.metric as EvalMetric] = r.value;
}
return { runId: latest.runId, createdAt: latest.createdAt.toISOString(), n: Math.max(...rows.map((r) => r.n)), values };
}
export async function getAbPairs(): Promise<{ pairs: AbPair[]; isSample: boolean }> {
const rows = await getDb().select().from(abPairs);
if (!rows.length) return { pairs: SAMPLE_AB_PAIRS, isSample: true };
return {
pairs: rows.map((r) => ({
id: r.id, author: "A customer", rating: r.rating, reviewText: r.reviewText, base: r.baseReply, tuned: r.tunedReply,
})),
isSample: false,
};
}
export async function getAbTally(): Promise<AbTally> {
const rows = await getDb()
.select({ choice: abVotes.choice, n: sql<number>`count(*)::int` })
.from(abVotes)
.where(like(abVotes.pairId, "v4-%"))
.groupBy(abVotes.choice);
const tally: AbTally = { base: 0, tuned: 0, same: 0 };
for (const r of rows) if (r.choice in tally) tally[r.choice as AbChoice] = r.n;
return tally;
}
export async function saveVote(pairId: string, choice: AbChoice) {
const db = getDb();
const s = await shopRow();
await db.insert(abVotes).values({ pairId, choice, shopId: s.id });
}
db:
schema.sql
ml:
data
pyproject.toml
replydesk_ml
runs
scripts
uv.lock
ml/scripts:
build_bait.py
build_dataset.py
eval4.py
eval.py
extract.py
load_examples.py
__pycache__
teacher.py
to_chat.py
train.py
scripts:
seed.ts
{
"name": "replydesk",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^3.0.68",
"@huggingface/transformers": "4.3.1",
"@phosphor-icons/react": "^2.1.10",
"ai": "^7.0.138",
"drizzle-orm": "^0.45.4",
"motion": "^14.1.0",
"next": "16.4.0",
"postgres": "^3.4.9",
"react": "19.3.0",
"react-dom": "19.3.0",
"server-only": "^0.0.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"eslint": "^10.12.0",
Working on it: criteria confirmed from the live page. Now saving the bait results to Tiger and /better, then building small retrieval evals for Tiger Data and Backboard.
$ grep -n "def save" -A60 ml/scripts/eval4.py | head -110; cat app/better/page.tsx | head -150; sed -n 1,98p lib/types.ts | grep -n "Eval" ; grep -n "eval_results" -B2 -A12 db/schema.sql; ls ml/data/eval/*; grep -n "GROQ\|HF_\|BACKBOARD\|DATABASE" .env.local | sed 's/=.*/=…/'
268:def save(rows: list[dict]) -> None:
269- import psycopg
270-
271- res = json.loads((OUT / "results.json").read_text())
272- judged = load("judge.jsonl")
273- g = gens(rows)
274- run_id = "v4-" + time.strftime("%Y%m%d-%H%M")
275- metrics = ["pass_all", "rule_detail", "rule_no_stock", "rule_no_arguing", "rule_acknowledges", "rule_length",
276- "unbacked_promise_rate", "unbacked_promise_rate_with_facts", "median_words", "latency_ms", "cost_per_reply",
277- "prompt_tokens"]
278- with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
279- for s in SYSTEMS:
280- for m in metrics:
281- if res[s].get(m) is not None:
282- cur.execute("INSERT INTO eval_results (system, metric, value, n, run_id) VALUES (%s,%s,%s,%s,%s)",
283- (s, m, float(res[s][m]), res[s]["n"], run_id))
284- w = [j["v4_won"] for j in judged if j["vs"] == s]
285- if w:
286- cur.execute("INSERT INTO eval_results (system, metric, value, n, run_id) VALUES (%s,%s,%s,%s,%s)",
287- (s, "v4_win_rate", sum(w) / len(w), len(w), run_id))
288- # Blind pairs: v4 against the careful prompt, the hardest honest opponent.
289- cur.execute("DELETE FROM ab_pairs")
290- for i in random.Random(11).sample(range(len(rows)), 40):
291- r = rows[i]
292- cur.execute(
293- "INSERT INTO ab_pairs (id, category, rating, review_text, base_reply, tuned_reply) VALUES (%s,%s,%s,%s,%s,%s)",
294- (f"v4-{i}", r["shop_type"], r["rating"], r["review"], g["careful"][i]["reply"], g["v4"][i]["reply"]),
295- )
296- print(f"saved to Tiger (run {run_id})")
297-
298-
299-async def main_async(step: str, rows: list[dict]) -> None:
300- if step == "gen":
301- await asyncio.gather(*(generate(s, rows) for s in SYSTEMS if s != "real"))
302- if step == "judge":
303- await judge(rows)
304- if step == "promise":
305- await promise_check(rows)
306-
307-
308-if __name__ == "__main__":
309- step = sys.argv[1]
310- rows = test_rows()
311- asyncio.run(main_async(step, rows))
312- if step == "report":
313- report(rows)
314- if step == "save":
315- save(rows)
import type { Metadata } from "next";
import Link from "next/link";
import { getAbTally, getEvalResults } from "@/lib/data";
import type { EvalMetric, EvalSystem } from "@/lib/types";
export const metadata: Metadata = { title: "Is it better?" };
export const dynamic = "force-dynamic";
const SYSTEMS: { id: EvalSystem; label: string }[] = [
{ id: "base", label: "Qwen3.5-4B, short prompt" },
{ id: "careful", label: "Qwen3.5-4B, long rulebook prompt" },
{ id: "v3", label: "Earlier tune (v3)" },
{ id: "v4", label: "ReplyDesk (v4, fine-tuned)" },
{ id: "real", label: "Real owner reply" },
];
const pct = (v: number) => `${Math.round(v * 100)}%`;
const METRICS: { id: EvalMetric; label: string; help: string; fmt: (v: number) => string }[] = [
{ id: "pass_all", label: "Passes every rule", help: "Short, answers the review, no stock phrase, no arguing, an apology on low stars, no unbacked promise. Higher is better.", fmt: pct },
{ id: "rule_detail", label: "Mentions a detail", help: "Reply repeats something the customer actually said. Higher is better.", fmt: pct },
{ id: "unbacked_promise_rate_with_facts", label: "Promises beyond the shop facts", help: "On reviews where the shop saved facts: refunds, discounts or call-backs the facts do not back. Lower is safer.", fmt: pct },
{ id: "median_words", label: "Median words", help: "Real owners write about 34.", fmt: (v) => String(Math.round(v)) },
{ id: "prompt_tokens", label: "Prompt size", help: "Tokens sent with every request. The tuned model needs no rulebook in the prompt.", fmt: (v) => String(Math.round(v)) },
{ id: "cost_per_reply", label: "Cost per reply", help: "Tokens in and out at Tinker's price for Qwen3.5-4B.", fmt: (v) => `$${v.toFixed(5)}` },
{ id: "latency_ms", label: "Latency", help: "Time to a full reply.", fmt: (v) => (v >= 1000 ? `${(v / 1000).toFixed(1)} s` : `${Math.round(v)} ms`) },
{ id: "v4_win_rate", label: "v4 wins head to head", help: "Judge (gpt-oss-120b) picked v4's reply over this column's, 100 reviews. 50% is a tie.", fmt: pct },
];
export default async function BetterPage() {
const [results, tally] = await Promise.all([getEvalResults(), getAbTally()]);
const votes = tally.base + tally.tuned + tally.same;
return (
<div className="page">
<h1>Is it better?</h1>
<p className="lede">
v4 against the same model with a short prompt, the same model with a long rulebook prompt, the earlier tune, and what real owners wrote.
300 held-out reviews from businesses the model never saw in training. Half of them come with shop facts, the way the app sends them.
The rule rows are plain code, not a model. The head-to-head row is a judge model from a different family.
</p>
{results ? (
<p className="note-sample" style={{ marginBottom: 18 }}>
Run {results.runId} · {results.n} held-out reviews · {new Date(results.createdAt).toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" })}
</p>
) : (
<div className="pending">
<span className="stamp">Pending</span>
<div>
<h2>Eval running, numbers land here.</h2>
<p>Nothing is filled in yet on purpose. This table reads straight from the eval results, so what you see later is what was measured.</p>
</div>
</div>
)}
<div className="table-wrap">
<table className="eval">
<caption className="sr-only">Eval results by system and metric</caption>
<thead>
<tr>
<th scope="col">Metric</th>
{SYSTEMS.map((s) => (
<th key={s.id} scope="col" className={s.id === "v4" ? "tuned" : undefined}>{s.label}</th>
))}
</tr>
</thead>
<tbody>
{METRICS.map((m) => (
<tr key={m.id}>
<th scope="row">{m.label}<small>{m.help}</small></th>
{SYSTEMS.map((s) => {
const v = results?.values[s.id]?.[m.id];
return (
<td key={s.id} className={s.id === "v4" ? "tuned" : undefined}>
{v == null ? <span className="dash" aria-label="not measured">—</span> : m.fmt(v)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{results && (
<section className="reading">
<h2>What the numbers say</h2>
<p>
The first tune (v3) copied the length of real owners but often skipped what the reviewer said, and the judge
preferred the prompted models. v4 was trained on replies that answer the review in 2 or 3 plain sentences,
with shop facts in the prompt, and it fixes that: it passes the rules about as often as a prompt that spells
every rule out, and it beats v3 head to head.
</p>
<p>
It does not beat the long-prompt model on quality. The judge calls it a tie. What it saves is the rulebook:
the same behavior from a prompt about half the size, a cheaper reply, and no instructions to keep up to date.
The rules were written from the same guide that made the training data, so the rule rows flatter it a little.
The head-to-head row and the blind test are the independent checks.
</p>
<p>
People get the last word. On the <Link href="/ab">Blind test</Link> page you see v4 against the long-prompt model
and pick the one you would send.
{votes > 0 && (
<> So far: {tally.tuned} for v4, {tally.base} for the long-prompt model, {tally.same} “about the same” ({votes} votes).</>
)}
</p>
</section>
)}
<p className="lede" style={{ marginTop: 22, fontSize: 14 }}>
A dash means that number has not been measured.
</p>
</div>
);
}
55:export type EvalSystem = "base" | "careful" | "v3" | "v4" | "real";
56:export type EvalMetric =
71:export type EvalResults = {
76: values: Partial<Record<EvalSystem, Partial<Record<EvalMetric, number>>>>;
61- ON reply_examples USING bm25 (review_text) WITH (text_config = 'english');
62-
63:CREATE TABLE IF NOT EXISTS eval_results (
64- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
65- system text NOT NULL, -- base | tuned | big | real
66- metric text NOT NULL, -- median_words | cliche_rate | mentions_detail | made_up_promises | win_rate_vs_base | cost_per_reply | latency_ms
67- value double precision NOT NULL,
68- n integer NOT NULL,
69- run_id text NOT NULL,
70- created_at timestamptz NOT NULL DEFAULT now()
71-);
72-
73-CREATE TABLE IF NOT EXISTS ab_votes (
74- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
75- pair_id text NOT NULL,
ml/data/eval/gen_base.jsonl
ml/data/eval/gen_big.jsonl
ml/data/eval/gen_tuned.jsonl
ml/data/eval/judge.jsonl
ml/data/eval/results.json
ml/data/eval/bait:
gen_base.jsonl
gen_careful.jsonl
gen_v3.jsonl
gen_v4.jsonl
judge.jsonl
promise.jsonl
results.json
ml/data/eval/v2:
gen_tuned.jsonl
judge.jsonl
results.json
ml/data/eval/v4:
gen_base.jsonl
gen_careful.jsonl
gen_v3.jsonl
gen_v4.jsonl
judge.jsonl
results.json
4:BACKBOARD_API_KEY=…
5:GROQ_API_KEY=…
6:DATABASE_URL=…
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && python3 -c "
import json;r=json.load(open('data/eval/bait/results.json'));print(json.dumps(r['v4'],indent=1))"; head -c 300 data/eval/bait/judge.jsonl; sed -n 50,98p ../lib/types.ts; grep -n "^OUT\|^def load\b" -A4 scripts/eval4.py | head -20
{
"n": 150,
"pass_all": 0.6866666666666666,
"rule_length": 1.0,
"rule_detail": 0.92,
"rule_no_stock": 0.9933333333333333,
"rule_no_arguing": 0.8933333333333333,
"rule_acknowledges": 1.0,
"rule_no_signoff": 1.0,
"rule_no_unbacked_promise": 0.86,
"unbacked_promise_rate": 0.14,
"unbacked_promise_rate_with_facts": 0.07792207792207792,
"unbacked_promise_rate_refund_bait": 0.18,
"n_refund_bait": 100,
"median_words": 34.5,
"latency_ms": 1060.0,
"cost_per_reply": 0.0001016492,
"prompt_tokens": 180.2
}
{"i": 80, "vs": "base", "v4_won": false}
{"i": 93, "vs": "base", "v4_won": false}
{"i": 142, "vs": "base", "v4_won": true}
{"i": 80, "vs": "careful", "v4_won": false}
{"i": 142, "vs": "careful", "v4_won": true}
{"i": 131, "vs": "base", "v4_won": false}
{"i": 142, "vs": "v3", "v4_won": true}
{"i": 13 costUsd: number;
};
export type PromiseCheck = { text: string; ok: boolean; note: string };
export type EvalSystem = "base" | "careful" | "v3" | "v4" | "real";
export type EvalMetric =
| "pass_all"
| "rule_detail"
| "rule_no_stock"
| "rule_no_arguing"
| "rule_acknowledges"
| "rule_length"
| "unbacked_promise_rate"
| "unbacked_promise_rate_with_facts"
| "median_words"
| "latency_ms"
| "cost_per_reply"
| "prompt_tokens"
| "v4_win_rate";
export type EvalResults = {
runId: string;
createdAt: string;
n: number;
// values[system][metric]; missing means "not measured"
values: Partial<Record<EvalSystem, Partial<Record<EvalMetric, number>>>>;
};
export type AbPair = {
id: string;
reviewText: string;
author: string;
rating: number;
base: string;
tuned: string;
};
export type AbChoice = "base" | "tuned" | "same";
export type AbTally = Record<AbChoice, number>;
export type ShopInput = {
name: string;
category: Category;
city: string;
ownerName: string;
facts: string[];
pastReplies: string[];
};
41:OUT = DATA / "eval" / ("v4" if SET == "test" else SET)
42:OUT.mkdir(parents=True, exist_ok=True)
43-
44-TUNED = {
45- "v3": "tinker://1cf358f7-dec5-50e7-b3fd-57db9f467d70:train:0/sampler_weights/final",
46- "v4": "tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final",
--
67:def load(name: str) -> list[dict]:
68- p = OUT / name
69- return [json.loads(line) for line in p.open()] if p.exists() else []
70-
71-
$ python3 - <<'E'
p='scripts/eval4.py'
s=open(p).read()
s=s.replace('''async def main_async(''','''def save_bait(rows: list[dict]) -> None:
"""Adds the bait numbers to the latest saved run, as bait_* metrics."""
import psycopg
res = json.loads((OUT / "results.json").read_text())
judged, checked = load("judge.jsonl"), load("promise.jsonl")
with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
cur.execute("SELECT run_id FROM eval_results WHERE run_id LIKE 'v4-%%' ORDER BY created_at DESC LIMIT 1")
run_id = cur.fetchone()[0]
cur.execute("DELETE FROM eval_results WHERE run_id = %s AND metric LIKE 'bait_%%'", (run_id,))
for s in SYSTEMS:
vals = {"bait_pass_all": res[s]["pass_all"], "bait_unbacked_promise_rate": res[s]["unbacked_promise_rate"]}
c = [j["unbacked"] for j in checked if j["s"] == s]
if c:
vals["bait_unbacked_model_check"] = sum(c) / len(c)
w = [j["v4_won"] for j in judged if j["vs"] == s]
if w:
vals["bait_v4_win_rate"] = sum(w) / len(w)
for m, v in vals.items():
cur.execute("INSERT INTO eval_results (system, metric, value, n, run_id) VALUES (%s,%s,%s,%s,%s)",
(s, m, float(v), res[s]["n"], run_id))
print(f"bait numbers added to {run_id}")
async def main_async(''',1)
s=s.replace(''' if step == "save":
save(rows)''',''' if step == "save":
save(rows)
if step == "savebait":
save_bait(rows)''')
open(p,'w').write(s)
E
set -a; . ../.env.local; set +a; EVAL_SET=bait uv run python scripts/eval4.py savebait 2>&1 | tail -3
bait numbers added to v4-20261017-1046
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && python3 - <<'E'
p='lib/types.ts'
s=open(p).read()
s=s.replace(''' | "v4_win_rate";''',''' | "v4_win_rate"
| "bait_pass_all"
| "bait_unbacked_promise_rate"
| "bait_unbacked_model_check"
| "bait_v4_win_rate";''')
open(p,'w').write(s)
p='app/better/page.tsx'
s=open(p).read()
s=s.replace('''export default async function BetterPage() {''','''const BAIT: { id: EvalMetric; label: string; help: string }[] = [
{ id: "bait_pass_all", label: "Passes every rule", help: "Same rules as above. Higher is better." },
{ id: "bait_unbacked_promise_rate", label: "Promises beyond the facts (code check)", help: "Refunds, discounts, free items or call-backs the shop never offered. Lower is safer." },
{ id: "bait_unbacked_model_check", label: "Promises beyond the facts (model check)", help: "A second opinion from gpt-oss-120b, which reads each reply and the facts. Lower is safer." },
{ id: "bait_v4_win_rate", label: "v4 wins head to head", help: "Judge picked v4's reply over this column's, 100 reviews. 50% is a tie." },
];
export default async function BetterPage() {''')
s=s.replace(''' {results && (
<section className="reading">
<h2>What the numbers say</h2>''',''' {results?.values.v4?.bait_pass_all != null && (
<section className="reading">
<h2>The hard test: reviews that ask for money</h2>
<p>
150 real 1 and 2 star reviews that demand a refund, a freebie or a call-back, from shops the model never saw
(none were in training, validation or the test above). A half of them come with shop facts that do not offer any of that.
This is where a made-up promise costs a shop real money.
</p>
<div className="table-wrap">
<table className="eval">
<caption className="sr-only">Results on reviews that ask for money</caption>
<thead>
<tr>
<th scope="col">Metric</th>
{SYSTEMS.map((s) => (
<th key={s.id} scope="col" className={s.id === "v4" ? "tuned" : undefined}>{s.label}</th>
))}
</tr>
</thead>
<tbody>
{BAIT.map((m) => (
<tr key={m.id}>
<th scope="row">{m.label}<small>{m.help}</small></th>
{SYSTEMS.map((s) => {
const v = results.values[s.id]?.[m.id];
return (
<td key={s.id} className={s.id === "v4" ? "tuned" : undefined}>
{v == null ? <span className="dash" aria-label="not measured">—</span> : pct(v)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p>
Here v4 does what the long prompt could not: it promises beyond the facts about half as often (14% against 31%),
and the judge picks it over the long-prompt model 79 times out of 100. Both checks agree, but both are still
models and code, not shop owners.
</p>
</section>
)}
{results && (
<section className="reading">
<h2>What the numbers say</h2>''')
open(p,'w').write(s)
E
grep -n "A half" app/better/page.tsx; sed -i 's/A half of them come/About half of them come/' app/better/page.tsx; bunx tsc --noEmit 2>&1 | tail -5
96: (none were in training, validation or the test above). A half of them come with shop facts that do not offer any of that.
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && cat pyproject.toml | sed -n 1,30p; sed -n 1,60p scripts/load_examples.py; grep -n "^FACTS" -A30 scripts/teacher.py | head -50; ls ../embed-space; ls data
[project]
name = "ml"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"backboard-sdk>=1.5.19",
"fastembed>=0.9.0",
"openai>=3.29.0",
"polars>=2.0.0",
"psycopg[binary]>=3.3.6",
"python-dotenv>=1.2.4",
"tinker>=0.33.1",
"tinker-cookbook>=0.5.7",
]
"""Embed the example pool on the laptop and load it into Tiger.
The app searches these with BM25 + vector (RRF) to show the model how real
owners answered similar reviews. Indexes are dropped during the bulk load and
rebuilt afterwards, which is much faster on the free shared-CPU service.
"""
import os
from pathlib import Path
import polars as pl
import psycopg
from dotenv import load_dotenv
from fastembed import TextEmbedding
ROOT = Path(__file__).resolve().parent.parent
load_dotenv(ROOT.parent.parent / ".env")
INDEXES = """
CREATE INDEX IF NOT EXISTS reply_examples_embedding_idx
ON reply_examples USING diskann (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS reply_examples_bm25_idx
ON reply_examples USING bm25 (review_text) WITH (text_config = 'english');
"""
def main() -> None:
pool = pl.read_parquet(ROOT / "data" / "examples_pool.parquet")
print(f"embedding {pool.height} reviews", flush=True)
model = TextEmbedding("BAAI/bge-small-en-v1.5")
vectors = list(model.embed(pool["review"].to_list(), batch_size=128))
with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
cur.execute("DROP INDEX IF EXISTS reply_examples_embedding_idx")
cur.execute("DROP INDEX IF EXISTS reply_examples_bm25_idx")
cur.execute("DELETE FROM reply_examples WHERE shop_id IS NULL")
with cur.copy("COPY reply_examples (category, review_text, reply_text, rating, embedding) FROM STDIN") as copy:
for row, vec in zip(pool.iter_rows(named=True), vectors):
emb = "[" + ",".join(f"{x:.5f}" for x in vec) + "]"
copy.write_row((row["shop_type"], row["review"], row["reply"], row["rating"], emb))
print("rows loaded, building indexes")
cur.execute(INDEXES)
cur.execute("SELECT count(*) FROM reply_examples")
print(f"reply_examples: {cur.fetchone()[0]} rows")
if __name__ == "__main__":
main()
50:FACTS = {
51- "cafe/restaurant": [
52- "Free refill on drip coffee",
53- "We remake any drink that is not right, just tell the person at the counter",
54- "Vegan and gluten-free options are on the menu every day",
55- "The kitchen closes 30 minutes before we do",
56- "Large orders: call a day ahead",
57- "Ask for the owner at the counter if anything went wrong",
58- "Free parking behind the building",
59- ],
60- "retail": [
61- "Returns within 14 days with a receipt",
62- "Free gift wrapping on request",
63- "We can special order anything not on the shelf, usually 3 to 5 days",
64- "Ask the owner at the front desk if anything went wrong",
65- "Store credit for returns without a receipt",
66- "Free repairs on anything we sold within 30 days",
67- ],
68- "home services": [
69- "Free estimates, no obligation",
70- "We come back within 7 days for free if the problem returns",
71- "We answer calls Monday to Saturday, 8 to 5",
72- "Ask for the owner by name if anything went wrong",
73- "Emergency visits the same day when we can",
74- "We clean up the work area before we leave",
75- ],
76- "dentist": [
77- "Same-week appointments for pain",
78- "We call to check in after bigger procedures",
79- "Ask for the office manager if anything went wrong",
80- "We explain costs before any treatment starts",
app.py
Dockerfile
README.md
requirements.txt
bait.jsonl
eval
eval_stale
examples_pool.parquet
pairs.parquet
raw
stats.json
test.jsonl
train_chat.jsonl
train.jsonl
v1
v2
v4
val_chat.jsonl
val.jsonl
$ sed -n 1,66p scripts/eval4.py; grep -n "^async def call" -A25 scripts/eval4.py; head -c 400 data/test.jsonl
"""Eval for v4: rule pass rates plus a small pairwise judge, on the 300 held-out reviews.
Half the reviews get shop facts (same format the app sends), so the eval also tests whether a
model promises only what the facts allow.
base Qwen3.5-4B, the app's prompt
careful Qwen3.5-4B, a long prompt that spells out every rule the rules check
v3 fine-tuned, the model the app used on Oct 16
v4 fine-tuned on teacher replies + shop facts
real the reply the real owner posted (reference, never saw the facts)
uv run python scripts/eval4.py gen # write replies (cached)
uv run python scripts/eval4.py report # rule pass rates -> data/eval/v4/results.json
uv run python scripts/eval4.py judge # pairwise judge, v4 vs each other system (100 reviews)
uv run python scripts/eval4.py save # results + 40 blind v4-vs-careful pairs -> Tiger (the /better and /ab pages)
"""
import asyncio
import json
import os
import random
import re
import statistics
import sys
import time
from pathlib import Path
from dotenv import load_dotenv
from openai import AsyncOpenAI, RateLimitError
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))
from replydesk_ml.prompt import messages # noqa: E402
from replydesk_ml.rules import check, unbacked_promises # noqa: E402
from teacher import FACTS, STYLE, clean # noqa: E402
load_dotenv(ROOT.parent.parent / ".env")
DATA = ROOT / "data"
SET = os.environ.get("EVAL_SET", "test") # "bait" = real refund-demanding reviews, see build_bait.py
OUT = DATA / "eval" / ("v4" if SET == "test" else SET)
OUT.mkdir(parents=True, exist_ok=True)
TUNED = {
"v3": "tinker://1cf358f7-dec5-50e7-b3fd-57db9f467d70:train:0/sampler_weights/final",
"v4": "tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final",
}
PRICE = (0.33, 1.005) # $ per 1M prompt / sampled tokens, Qwen3.5-4B
SYSTEMS = ["base", "careful", "v3", "v4", "real"]
JUDGE = "openai/gpt-oss-120b"
JUDGE_N = 100
client = AsyncOpenAI(
base_url="https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1", api_key=os.environ["TINKER_API_KEY"]
)
def test_rows() -> list[dict]:
rows = [json.loads(line) for line in (DATA / ("test.jsonl" if SET == "test" else f"{SET}.jsonl")).open()]
for i, r in enumerate(rows):
rng = random.Random(f"eval-{i}")
if rng.random() < 0.5:
r["facts"] = rng.sample(FACTS[r["shop_type"]], rng.randint(1, 3))
return rows
81:async def call(sem, model, msgs, **kw):
82- async with sem:
83- for attempt in range(8):
84- try:
85- t = time.time()
86- r = await client.chat.completions.create(model=model, messages=msgs, **kw)
87- return r, time.time() - t
88- except RateLimitError:
89- await asyncio.sleep(min(60, 5 * 2**attempt))
90- raise RuntimeError("rate limited too many times")
91-
92-
93-async def generate(system: str, rows: list[dict]) -> None:
94- path = OUT / f"gen_{system}.jsonl"
95- done = {g["i"] for g in load(path.name)}
96- sem = asyncio.Semaphore(6)
97- model = TUNED.get(system, "Qwen/Qwen3.5-4B")
98-
99- async def one(i, row):
100- msgs = messages(row, with_reply=False)
101- if system == "careful":
102- msgs = [{"role": "system", "content": careful_system(row)}, msgs[1]]
103- r, dt = await call(sem, model, msgs, max_tokens=220, temperature=0.7, extra_body={"reasoning_effort": False})
104- cost = (r.usage.prompt_tokens * PRICE[0] + r.usage.completion_tokens * PRICE[1]) / 1e6
105- return {"i": i, "reply": clean(r.choices[0].message.content or ""), "latency_s": round(dt, 2), "cost_usd": cost,
106- "tokens": [r.usage.prompt_tokens, r.usage.completion_tokens]}
{"gmap_id":"0x4cb506dba70910fd:0xf5aed92be5600560","business":"Nelson Ace Hardware","shop_type":"retail","categories":"Hardware store|Home improvement store|Paint store","state":"Vermont","author":"Robert T Farrell","rating":5,"review":"Always have answers to my questions & variety of hardware items to solve situations. Very helpful and knowledgeable staff!","reply":"Thank you so much Robert, we a
Writing the Tiger Data retrieval eval (BM25 vs vector vs fused, judged for relevance).
/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval_retrieval.py
File created successfully at: /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval_retrieval.py (file state is current in your context — no need to Read it back)
$ python3 - <<'E'
p='scripts/eval_retrieval.py'
s=open(p).read()
a=s.index('def save()')
b=s.index('if __name__')
s=s[:a]+'''def save() -> None:
"""Adds retrieval_* metrics to the latest saved v4 run (system = search method)."""
s = stats()
with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
cur.execute("SELECT run_id FROM eval_results WHERE run_id LIKE 'v4-%%' ORDER BY created_at DESC LIMIT 1")
run_id = cur.fetchone()[0]
cur.execute("DELETE FROM eval_results WHERE run_id = %s AND metric LIKE 'retrieval_%%'", (run_id,))
for m, v in s.items():
for k in ("precision_at_3", "hit_at_3"):
cur.execute("INSERT INTO eval_results (system, metric, value, n, run_id) VALUES (%s,%s,%s,%s,%s)",
(m, "retrieval_" + k, v[k], v["n"], run_id))
print("saved to", run_id)
'''+s[b:]
s=s.replace("-> Tiger eval_results (run id retrieval-...)","-> Tiger eval_results (latest v4 run)")
open(p,'w').write(s)
E
set -a; . ../.env.local; set +a; uv run python scripts/eval_retrieval.py run 2>&1 | tail -5 && uv run python scripts/eval_retrieval.py report
Fetching 5 files: 0%| | 0/5 [00:00<?, ?it/s]
Fetching 5 files: 20%|██ | 1/5 [00:00<00:03, 1.03it/s]
Fetching 5 files: 80%|████████ | 4/5 [00:01<00:00, 4.52it/s]
Fetching 5 files: 100%|██████████| 5/5 [00:10<00:00, 2.18s/it]
judged 120 of 120 queries
bm25 precision@3 0.65 at least one relevant in top 3: 0.87 (n=120)
vector precision@3 0.71 at least one relevant in top 3: 0.89 (n=120)
fused precision@3 0.72 at least one relevant in top 3: 0.89 (n=120)
$ set -a; . ../.env.local; set +a; uv run python scripts/eval_retrieval.py save; python3 - <<'E'
import os,json,urllib.request,time
B="https://app.backboard.io/api"
def call(path,method="GET",body=None):
req=urllib.request.Request(B+path,method=method,data=json.dumps(body).encode() if body is not None else None,headers={"X-API-Key":os.environ["BACKBOARD_API_KEY"],"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(req,timeout=20).read() or b"{}")
a=call("/assistants","POST",{"name":"ReplyDesk eval probe"});print(a)
aid=a.get("assistant_id") or a.get("id")
for f in ["Free parking behind the building","The kitchen closes 30 minutes before we do","Free refill on drip coffee"]:
print(call(f"/assistants/{aid}/memories","POST",{"content":f}))
time.sleep(3)
print(call(f"/assistants/{aid}/memories/search","POST",{"query":"waited forever for my coffee and nowhere to park","limit":5}))
print(call(f"/assistants/{aid}","DELETE"))
E
saved to v4-20261017-1046
{'name': 'ReplyDesk eval probe', 'description': None, 'system_prompt': None, 'tools': None, 'tok_k': 10, 'embedding_provider': 'openai', 'embedding_model_name': 'text-embedding-3-large', 'embedding_dims': 3072, 'custom_fact_extraction_prompt': None, 'custom_update_memory_prompt': None, 'assistant_id': '7fe890c0-4bad-48e1-8750-6addf5cbbaba', 'created_at': '2026-10-17T05:38:28'}
{'success': True, 'message': 'Memory added successfully', 'memory_id': 'a199b32d-b434-44f9-9780-05bbb65b3177', 'content': 'Free parking behind the building'}
{'success': True, 'message': 'Memory added successfully', 'memory_id': '034f0659-41f6-4b9c-8ac5-632ced4a8797', 'content': 'The kitchen closes 30 minutes before we do'}
{'success': True, 'message': 'Memory added successfully', 'memory_id': '311737b5-850a-4c40-9e98-c50414134c44', 'content': 'Free refill on drip coffee'}
{'memories': [{'id': 'a199b32d-b434-44f9-9780-05bbb65b3177', 'content': 'Free parking behind the building', 'score': 0.60357475, 'created_at': '2026-10-17T05:38:30.692958Z', 'updated_at': None}, {'id': '034f0659-41f6-4b9c-8ac5-632ced4a8797', 'content': 'The kitchen closes 30 minutes before we do', 'score': 0.8603223, 'created_at': '2026-10-17T05:38:34.731271Z', 'updated_at': None}, {'id': '311737b5-850a-4c40-9e98-c50414134c44', 'content': 'Free refill on drip coffee', 'score': 0.69313896, 'created_at': '2026-10-17T05:38:36.451346Z', 'updated_at': None}], 'total_count': 3}
{'message': 'Assistant deleted successfully', 'assistant_id': '7fe890c0-4bad-48e1-8750-6addf5cbbaba', 'deleted_at': '2026-10-17T05:38:42.936398'}
/home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval_memory.py
File created successfully at: /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml/scripts/eval_memory.py (file state is current in your context — no need to Read it back)
$ set -a; . ../.env.local; set +a; uv run python scripts/eval_memory.py run 2>&1 | tail -4 && uv run python scripts/eval_memory.py report
120 reviews labelled, 46 need at least one fact
all facts recall 1.00 precision 0.25 facts per reply 6.2 (n=46 reviews that need a fact)
memory, first 5 (what the app did) recall 1.00 precision 0.31 facts per reply 5.0 (n=46 reviews that need a fact)
memory, best 3 recall 0.11 precision 0.08 facts per reply 3.0 (n=46 reviews that need a fact)
memory, best 2 recall 0.06 precision 0.05 facts per reply 2.0 (n=46 reviews that need a fact)
memory, score >= 0.5 recall 0.96 precision 0.49 facts per reply 3.0 (n=46 reviews that need a fact)
$ python3 - <<'E'
import json
d=json.load(open('data/eval/memory/memory.json'))
need=[];no=[]
for o in d:
for c,s in o['search']:
(need if c in o['needed'] else no).append(s)
import statistics as st
print(len(need),st.mean(need),min(need),max(need)); print(len(no),st.mean(no),min(no),max(no))
for t in [.5,.6,.65,.7,.75,.8,.9]:
print(t, sum(s<=t for s in need)/len(need), sum(s<=t for s in no)/len(no))
E
72 0.7356823594444445 0.60852057 0.86422825
668 0.8123181835479042 0.589797 1.0150791
0.5 0.0 0.0
0.6 0.0 0.0014970059880239522
0.65 0.08333333333333333 0.011976047904191617
0.7 0.25 0.041916167664670656
0.75 0.6111111111111112 0.1347305389221557
0.8 0.8888888888888888 0.4086826347305389
0.9 1.0 0.9251497005988024
$ python3 - <<'E'
p='scripts/eval_memory.py'
s=open(p).read()
s=s.replace(''' ranked = [c for c, _ in sorted(o["search"], key=lambda x: -x[1])]
top = o["search"] and max(s for _, s in o["search"])
''',''' # The score is a distance: lower means closer to the review.
ranked = [c for c, _ in sorted(o["search"], key=lambda x: x[1])]
''')
s=s.replace(''' "memory, best 3": ranked[:3],
"memory, best 2": ranked[:2],
"memory, score >= 0.5": [c for c, s in o["search"] if s >= 0.5][:3],''',''' "memory, closest 3": ranked[:3],
"memory, closest 2": ranked[:2],
"memory, distance <= 0.78, at most 3": [c for c, s in sorted(o["search"], key=lambda x: x[1]) if s <= 0.78][:3],''')
open(p,'w').write(s)
E
uv run python scripts/eval_memory.py report
all facts recall 1.00 precision 0.25 facts per reply 6.2 (n=46 reviews that need a fact)
memory, first 5 (what the app did) recall 1.00 precision 0.31 facts per reply 5.0 (n=46 reviews that need a fact)
memory, closest 3 recall 0.89 precision 0.44 facts per reply 3.0 (n=46 reviews that need a fact)
memory, closest 2 recall 0.70 precision 0.51 facts per reply 2.0 (n=46 reviews that need a fact)
memory, distance <= 0.78, at most 3 recall 0.75 precision 0.48 facts per reply 1.7 (n=46 reviews that need a fact)
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && python3 - <<'E'
p='lib/backboard.ts'
s=open(p).read()
s=s.replace('''/** Facts that match the review. Throws on failure so the caller can fall back. */
export async function searchFacts(assistantId: string, query: string, limit = 5): Promise<string[]> {
const r = await call<{ memories?: Memory[] }>(`/assistants/${assistantId}/memories/search`, {
method: "POST",
body: { query, limit },
}, 4000);
return (r.memories ?? []).map((m) => m.content);
}''','''/**
* The closest facts to the review. Throws on failure so the caller can fall back.
* The search returns every memory up to the limit, in the order they were saved, with a score that is
* a distance (lower is closer), so the closest ones are picked here, not by position.
*/
export async function searchFacts(assistantId: string, query: string, limit = 3): Promise<string[]> {
const r = await call<{ memories?: Memory[] }>(`/assistants/${assistantId}/memories/search`, {
method: "POST",
body: { query, limit: 10 },
}, 4000);
return (r.memories ?? [])
.sort((a, b) => (a.score ?? Infinity) - (b.score ?? Infinity))
.slice(0, limit)
.map((m) => m.content);
}''')
open(p,'w').write(s)
p='lib/facts.ts'
s=open(p).read()
s=s.replace("searchFacts(shop.backboardAssistantId, reviewText, 5)","searchFacts(shop.backboardAssistantId, reviewText, 3)")
open(p,'w').write(s)
E
bunx tsc --noEmit | tail -3; cd ml && set -a; . ../.env.local; set +a; uv run python scripts/eval_memory.py save
saved to v4-20261017-1046
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && python3 - <<'E'
p='app/better/page.tsx'
s=open(p).read()
s=s.replace('''export default async function BetterPage() {''','''const RETRIEVAL = [
{ id: "bm25", label: "BM25 only (words)" },
{ id: "vector", label: "Vector only (meaning)" },
{ id: "fused", label: "Both, fused (what the app does)" },
];
const MEMORY = [
{ id: "all facts", label: "Send every fact" },
{ id: "memory, first 5 (what the app did)", label: "Memory, first 5 (the first version of the app)" },
{ id: "memory, closest 3", label: "Memory, closest 3 (what the app does now)" },
{ id: "memory, closest 2", label: "Memory, closest 2" },
];
export default async function BetterPage() {''',1)
s=s.replace(''' {results && (
<section className="reading">
<h2>What the numbers say</h2>''',''' {results && (() => {
const v = results.values as Record<string, Record<string, number | undefined>>;
if (v.fused?.retrieval_precision_at_3 == null && v["memory, closest 3"]?.memory_recall == null) return null;
return (
<section className="reading">
<h2>The parts around the model</h2>
{v.fused?.retrieval_precision_at_3 != null && (
<>
<p>
<strong>Tiger Data search.</strong> For 120 held-out reviews, each search returned its top 3 past reviews from the same trade.
A judge model, not told which search found which, marked whether each past review was the same kind of situation.
</p>
<div className="table-wrap">
<table className="eval">
<caption className="sr-only">Search quality by method</caption>
<thead><tr><th scope="col">Search</th><th scope="col">Relevant in top 3</th><th scope="col">At least one relevant</th></tr></thead>
<tbody>
{RETRIEVAL.map((m) => (
<tr key={m.id}>
<th scope="row">{m.label}</th>
<td className={m.id === "fused" ? "tuned" : undefined}>{pct(v[m.id]?.retrieval_precision_at_3 ?? 0)}</td>
<td className={m.id === "fused" ? "tuned" : undefined}>{pct(v[m.id]?.retrieval_hit_at_3 ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p>
Fusing is the best of the three, but only by a point over vectors alone, which is inside the noise of 120 reviews.
Words alone are clearly weaker. So the fusion earns its place mostly by never being the worst.
</p>
</>
)}
{v["memory, closest 3"]?.memory_recall != null && (
<>
<p>
<strong>Backboard memory.</strong> 120 held-out reviews across the six shop types, 46 of which need a saved fact
(a judge model marked which facts a good reply would use). Recall is how many of the needed facts reach the model.
Precision is how many of the facts sent were needed.
</p>
<div className="table-wrap">
<table className="eval">
<caption className="sr-only">Fact choice by method</caption>
<thead><tr><th scope="col">Facts sent to the model</th><th scope="col">Needed facts found</th><th scope="col">Facts sent that were needed</th><th scope="col">Facts per reply</th></tr></thead>
<tbody>
{MEMORY.map((m) => (
<tr key={m.id}>
<th scope="row">{m.label}</th>
<td className={m.id.endsWith("closest 3") ? "tuned" : undefined}>{pct(v[m.id]?.memory_recall ?? 0)}</td>
<td className={m.id.endsWith("closest 3") ? "tuned" : undefined}>{pct(v[m.id]?.memory_precision ?? 0)}</td>
<td className={m.id.endsWith("closest 3") ? "tuned" : undefined}>{(v[m.id]?.memory_facts_sent ?? 0).toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p>
This test found a bug in my first version. Backboard's search hands back every memory up to the limit, in the order they were
saved, and its score is a distance. With about 6 facts per shop and a limit of 5, the app was sending almost all facts. Picking the
closest 3 by score keeps 89% of the needed facts and sends half as many.
</p>
</>
)}
</section>
);
})()}
{results && (
<section className="reading">
<h2>What the numbers say</h2>''',1)
open(p,'w').write(s)
E
bunx tsc --noEmit | tail; bunx eslint app/better lib 2>&1 | tail -5
(Bash completed with no output)
$ which chromium chromium-browser google-chrome brave-browser playwright convert magick 2>&1 | grep -v "no "; ls ~/.cache/ms-playwright 2>/dev/null; python3 -c "import PIL;print('pil',PIL.__version__)"; grep -n "rate\|limit\|draft" app/actions.ts | head -30; cat lib/model.ts | sed -n 1,60p
chromium not found
chromium-browser not found
/usr/bin/google-chrome
/usr/bin/brave-browser
/home/anish-kumar/.local/bin/playwright
convert not found
magick not found
b
chromium-1234
chromium-1243
chromium_headless_shell-1234
chromium_headless_shell-1243
ffmpeg-1011
pil 10.2.0
5:import { draftReply } from "@/lib/model";
17:const draftIn = z.object({
24:export async function draftAction(
25: raw: z.input<typeof draftIn>,
26:): Promise<{ ok: true; draft: DraftResult; promises: PromiseCheck[]; examples: ReplyExample[] } | Fail> {
28: const { reviewId, tweak, embedding } = draftIn.parse(raw);
38: const draft = await draftReply({ shop, review, tweak, examples, facts });
39: const promises = await checkPromises(draft.reply, facts);
40: return { ok: true, draft, promises, examples };
42: console.error("draftAction", e);
43: return fail(e, "Could not draft a reply. Check the model settings and try again.");
import { buildPrompt, type PromptInput } from "./prompt";
import type { DraftResult, ReplyExample } from "./types";
const DEFAULT_BASE_URL = "https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1";
// Tinker prices per million tokens (Qwen3.5-4B): prefill $0.33, sample $1.005.
const PREFILL_PER_TOKEN = 0.33 / 1e6;
const SAMPLE_PER_TOKEN = 1.005 / 1e6;
export type DraftInput = PromptInput & {
// Shown in the UI only. Never sent to the model.
examples?: ReplyExample[];
};
export function modelIsConfigured() {
return Boolean(process.env.TINKER_MODEL_PATH);
}
export async function draftReply(input: DraftInput): Promise<DraftResult> {
const started = Date.now();
const modelPath = process.env.TINKER_MODEL_PATH;
if (!modelPath) {
return { reply: mockReply(input), model: "offline-sample", latencyMs: Date.now() - started + 40, costUsd: 0 };
}
const { system, user } = buildPrompt(input);
const base = (process.env.TINKER_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
const res = await fetch(`${base}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.TINKER_API_KEY ?? ""}` },
body: JSON.stringify({
model: modelPath,
messages: [{ role: "system", content: system }, { role: "user", content: user }],
temperature: 0.7,
max_tokens: 220,
// Qwen3.5 is a hybrid thinking model. Tinker wants the boolean false, not "none".
reasoning_effort: false,
}),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`Tinker returned ${res.status}`);
const json = (await res.json()) as {
choices?: { message?: { content?: string | null } }[];
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const text = json.choices?.[0]?.message?.content?.trim();
if (!text) throw new Error("Tinker returned an empty reply");
const inTok = json.usage?.prompt_tokens ?? 0;
const outTok = json.usage?.completion_tokens ?? 0;
return {
reply: `${text} — ${input.shop.ownerName}`,
model: modelPath.startsWith("tinker://") ? "Qwen3.5-4B (fine-tuned)" : modelPath,
latencyMs: Date.now() - started,
costUsd: inTok * PREFILL_PER_TOKEN + outTok * SAMPLE_PER_TOKEN,
};
}
/* ---------- offline mock: deterministic, no network ---------- */
$ sed -n 1,60p app/actions.ts
"use server";
import { z } from "zod";
import * as data from "@/lib/data";
import { draftReply } from "@/lib/model";
import { factsForReview } from "@/lib/facts";
import { checkPromises } from "@/lib/guardrail";
import { CATEGORIES } from "@/lib/types";
import type { DraftResult, PromiseCheck, ReplyExample, Review, Shop, AbChoice } from "@/lib/types";
type Fail = { ok: false; error: string };
const fail = (e: unknown, fallback: string): Fail => ({
ok: false,
error: e instanceof z.ZodError ? e.issues[0]?.message ?? fallback : fallback,
});
const draftIn = z.object({
reviewId: z.string().min(1),
tweak: z.enum(["shorter", "warmer", "offer_fix"]).optional(),
// Review vector made in the browser. Optional: without it the search uses words only.
embedding: z.array(z.number().finite()).length(384).optional(),
});
export async function draftAction(
raw: z.input<typeof draftIn>,
): Promise<{ ok: true; draft: DraftResult; promises: PromiseCheck[]; examples: ReplyExample[] } | Fail> {
try {
const { reviewId, tweak, embedding } = draftIn.parse(raw);
const [review, shop] = await Promise.all([data.getReview(reviewId), data.getShop()]);
if (!review) return { ok: false, error: "That review was not found." };
const [examples, facts] = await Promise.all([
data.findSimilarReplies(review.text, shop.category, 3, embedding, review.rating).catch((e) => {
console.error("findSimilarReplies", e instanceof Error ? e.message : e);
return [];
}),
factsForReview(shop, review.text),
]);
const draft = await draftReply({ shop, review, tweak, examples, facts });
const promises = await checkPromises(draft.reply, facts);
return { ok: true, draft, promises, examples };
} catch (e) {
console.error("draftAction", e);
return fail(e, "Could not draft a reply. Check the model settings and try again.");
}
}
export async function checkAction(text: string): Promise<PromiseCheck[]> {
const shop = await data.getShop();
return checkPromises(String(text).slice(0, 4000), shop.facts);
}
const reviewIn = z.object({
author: z.string().trim().max(80).default(""),
rating: z.number().int().min(1, "Pick a rating from 1 to 5.").max(5, "Pick a rating from 1 to 5."),
text: z.string().trim().min(3, "Paste the review text.").max(4000),
});
export async function addReviewAction(raw: z.input<typeof reviewIn>): Promise<{ ok: true; id: string; reviews: Review[] } | Fail> {
try {
const input = reviewIn.parse(raw);
Adding a small rate limit to the draft action so strangers can't drain the Tinker credit.
$ cat > lib/rate-limit.ts <<'E'
import "server-only";
import { headers } from "next/headers";
// Per-instance counters. Not exact on serverless (each instance counts alone), but it stops a loop
// from spending the Tinker credit: a few cents of drafts at most per instance per day.
const PER_IP = { max: 30, windowMs: 10 * 60_000 };
const PER_DAY = 1500;
const hits = new Map<string, number[]>();
let day = { start: Date.now(), n: 0 };
export async function allowDraft(): Promise<boolean> {
const now = Date.now();
if (now - day.start > 86_400_000) day = { start: now, n: 0 };
if (day.n >= PER_DAY) return false;
const h = await headers();
const ip = (h.get("x-forwarded-for") ?? "").split(",")[0].trim() || "unknown";
const recent = (hits.get(ip) ?? []).filter((t) => now - t < PER_IP.windowMs);
if (recent.length >= PER_IP.max) return false;
recent.push(now);
hits.set(ip, recent);
if (hits.size > 5000) for (const [k, v] of hits) if (!v.some((t) => now - t < PER_IP.windowMs)) hits.delete(k);
day.n += 1;
return true;
}
E
python3 - <<'E'
p='app/actions.ts'
s=open(p).read()
s=s.replace('import { factsForReview } from "@/lib/facts";','import { factsForReview } from "@/lib/facts";\nimport { allowDraft } from "@/lib/rate-limit";')
s=s.replace(''' const { reviewId, tweak, embedding } = draftIn.parse(raw);
''',''' const { reviewId, tweak, embedding } = draftIn.parse(raw);
if (!(await allowDraft())) return { ok: false, error: "That is a lot of drafts in a short time. Wait a few minutes and try again." };
''')
open(p,'w').write(s)
E
bunx tsc --noEmit | tail -3; bunx eslint . 2>&1 | tail -5
(Bash completed with no output)
$ grep -rl "shots" --include=*.py --include=*.ts --include=*.mjs --include=*.js --include=*.md . 2>/dev/null | grep -v node_modules | head; ls /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad | head -30; python3 -c "import PIL.Image as I;[print(f,I.open('docs/shots/'+f).size) for f in ['inbox-desktop.png','better-desktop.png','ab-desktop.png']]"
(eval):1: no matches found: --include=*.py
devto
pilot_q.txt
retry.sh
try.ts
inbox-desktop.png (1440, 900)
better-desktop.png (1440, 900)
ab-desktop.png (1440, 900)
$ (bun run dev -p 3111 > /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/dev.log 2>&1 &) ; sleep 8; curl -s -o /dev/null -w "%{http_code}\n" localhost:3111/better; tail -3 /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/dev.log
200
✓ Running next.config.ts took 124ms
GET /better 200 in 3.3s (next.js: 172ms, application-code: 3.1s)
$ mkdir -p /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/shots && cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad && cat > shot.py <<'E'
import asyncio, sys
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
pg = await b.new_page(viewport={"width":1440,"height":900}, device_scale_factor=2)
await pg.goto("http://localhost:3111/better", wait_until="networkidle")
await pg.screenshot(path="shots/better-full.png", full_page=True)
await pg.goto("http://localhost:3111/", wait_until="networkidle")
await pg.screenshot(path="shots/inbox0.png")
print(await pg.inner_text("body")[:0] if False else (await pg.inner_text("body"))[:800])
await b.close()
asyncio.run(main())
E
python3 shot.py
Exit code 1
Traceback (most recent call last):
File "/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/shot.py", line 13, in <module>
asyncio.run(main())
File "/usr/lib/python3.12/asyncio/runners.py", line 194, in run
return runner.run(main)
^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/asyncio/runners.py", line 118, in run
return self._loop.run_until_complete(task)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/asyncio/base_events.py", line 687, in run_until_complete
return future.result()
^^^^^^^^^^^^^^^
File "/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/shot.py", line 9, in main
await pg.goto("http://localhost:3111/", wait_until="networkidle")
File "/home/anish-kumar/.local/lib/python3.12/site-packages/playwright/async_api/_generated.py", line 9869, in goto
await self._impl_obj.goto(
File "/home/anish-kumar/.local/lib/python3.12/site-packages/playwright/_impl/_page.py", line 560, in goto
return await self._main_frame.goto(**locals_to_params(locals()))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/anish-kumar/.local/lib/python3.12/site-packages/playwright/_impl/_frame.py", line 156, in goto
await self._channel.send(
File "/home/anish-kumar/.local/lib/python3.12/site-packages/playwright/_impl/_connection.py", line 76, in send
return await self._connection.wrap_api_call(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/anish-kumar/.local/lib/python3.12/site-packages/playwright/_impl/_connection.py", line 632, in wrap_api_call
raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None
playwright._impl._errors.TimeoutError: Page.goto: Timeout 30000ms exceeded.
Call log:
- navigating to "http://localhost:3111/", waiting until "networkidle"
/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/shots/better-full.png
[Image: original 2880x7442, displayed at 774x2000. Multiply coordinates by 3.72 to map to original image.]
$ grep -n "\.reading\|\.table-wrap\|table.eval\|\.page {" -A8 app/globals.css | head -80
159:.page { max-width: 980px; padding: 44px 64px 80px; }
160-.page h1 { font: 400 44px/1.1 var(--font-fraunces), serif; letter-spacing: -.015em; margin: 0 0 12px; }
161-.page .lede { color: var(--ink-2); font-size: 16px; max-width: 58ch; margin: 0 0 36px; }
162-.paper { background: var(--slip); border: 1.5px solid var(--ink); padding: 28px 32px; }
163-.paper + .paper { margin-top: 28px; }
164-.paper h2 { font: 600 20px var(--font-fraunces), serif; margin: 0 0 18px; }
165-.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
166-.facts { list-style: none; margin: 0 0 12px; padding: 0; border-top: 1.5px solid var(--ink); }
167-.facts li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--rule); }
--
175:.table-wrap { overflow-x: auto; border: 1.5px solid var(--ink); background: var(--slip); }
176:table.eval { border-collapse: collapse; width: 100%; min-width: 720px; font-size: 14px; }
177-.eval th, .eval td { padding: 12px 14px; text-align: right; border-bottom: 1px solid var(--rule); font-variant-numeric: tabular-nums; }
178-.eval thead th { font: 700 13px var(--font-manrope), sans-serif; border-bottom: 1.5px solid var(--ink); background: var(--paper-2); vertical-align: bottom; }
179-.eval th:first-child, .eval td:first-child { text-align: left; }
180-.eval tbody th { font-weight: 700; }
181-.eval tbody th small { display: block; font-weight: 500; color: var(--ink-2); font-size: 12px; max-width: 34ch; }
182-.eval .tuned { box-shadow: inset 2px 0 0 var(--stamp), inset -2px 0 0 var(--stamp); }
183-.eval thead .tuned { color: var(--stamp); box-shadow: inset 2px 0 0 var(--stamp), inset -2px 0 0 var(--stamp), inset 0 2px 0 var(--stamp); }
184-.eval tbody tr:last-child .tuned { box-shadow: inset 2px 0 0 var(--stamp), inset -2px 0 0 var(--stamp), inset 0 -2px 0 var(--stamp); }
--
214:.reading { max-width: 62ch; margin-top: 36px; }
215:.reading h2 { font: 600 22px var(--font-fraunces), serif; margin: 0 0 10px; }
216:.reading p { color: var(--ink-2); margin: 0 0 12px; }
217:.reading a { color: var(--ink); text-decoration: underline; }
218-.note-sample { font-size: 13px; color: var(--ink-2); border-left: 3px solid var(--stamp-soft); padding-left: 12px; margin: 0 0 24px; }
219-
220-/* ---------- phone ---------- */
221-@media (max-width: 860px) {
222- .topbar { padding: 0 16px; }
223- .topbar .logo { padding: 10px 0 0; font-size: 24px; }
224- .topnav { width: 100%; overflow-x: auto; margin: 0 -8px; padding: 6px 8px 8px; }
225- .topnav { gap: 0; }
--
244: .page { padding: 28px 16px 56px; }
245- .page h1 { font-size: 34px; }
246- .paper { padding: 20px 16px; }
247- .grid2 { grid-template-columns: 1fr; }
248- .vs { grid-template-columns: minmax(0, 1fr); }
249- .ab-pair { grid-template-columns: 1fr; }
250- .ab-pair .slip.l, .ab-pair .slip.r { transform: none; }
251- .sysnames { grid-template-columns: 1fr; }
252- .tally b { font-size: 30px; }
$ python3 - <<'E'
p='app/better/page.tsx'
s=open(p).read()
s=s.replace('''<section className="reading">
<h2>The hard test''','''<section className="reading wide">
<h2>The hard test''')
s=s.replace('''<section className="reading">
<h2>The parts around the model</h2>''','''<section className="reading wide">
<h2>The parts around the model</h2>''')
s=s.replace('<table className="eval">\n <caption className="sr-only">Search quality','<table className="eval small">\n <caption className="sr-only">Search quality')
s=s.replace('<table className="eval">\n <caption className="sr-only">Fact choice','<table className="eval small">\n <caption className="sr-only">Fact choice')
open(p,'w').write(s)
p='app/globals.css'
s=open(p).read()
s=s.replace('.reading a {','.reading.wide { max-width: none; }\n.reading.wide p { max-width: 62ch; }\n.reading.wide .table-wrap { margin: 14px 0 16px; }\ntable.eval.small { min-width: 0; }\n.reading a {',1)
open(p,'w').write(s)
E
grep -c "eval small" app/better/page.tsx
cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad && sed -i 's/networkidle/load/' shot.py && sed -i 's#await pg.screenshot(path="shots/inbox0.png")#await pg.wait_for_timeout(4000); await pg.screenshot(path="shots/inbox0.png")#' shot.py && python3 shot.py
2
ReplyDesk
Inbox
Shop setup
Is it better?
Blind test
Review inbox for HOP-In Café
Reviews
3 to answer
HOP-In Café · Naya Raipur
4/5
stars
Jyoti Prajapati
19h ago
Small bakery inside the campus. They will offer Veg and non-veg Momos, Pizzas, grilled chicken, pastries, and various food items. As market is far from campus students will go there. In nominal cost you can get tasty food.
REPLIED
2/5
stars
Rohan M.
Tue
Car was ready a day late and nobody called to tell me. Work itself was fine, brakes feel good now, but I had to take leave twice.
NEEDS REPLY
1/5
stars
Ankit P.
Mon
Charged ₹800 for a check that took five minutes.
NEEDS REPLY
4/5
stars
Meera K.
Mon
Good service, the waiting area could be cleaner.
NEEDS REPLY
5/5
stars
Priya S.
Sat
Ramesh bhai fixed the AC in under an hour.
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad && cat > shot2.py <<'E'
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
pg = await b.new_page(viewport={"width":1440,"height":900}, device_scale_factor=2)
await pg.goto("http://localhost:3111/", wait_until="load")
await pg.wait_for_timeout(3000)
await pg.get_by_text("Charged ₹800").first.click()
await pg.wait_for_timeout(15000)
await pg.screenshot(path="shots/inbox1.png")
print((await pg.inner_text("body"))[-1500:])
await b.close()
asyncio.run(main())
E
python3 shot2.py
e try to have our staff be attentive during a customer's entire stay in our establishment. We have talked with all our staff regarding this issue. Please give us another chance.”
How a similar shop replied to “A change in menu items since our last visit leis disappointed. Service was fair, no one ch…”
“Amanda I apologize if you had poor service, that is not typical of my staff.. some items take less time than others to cook and come out faster, hence why others may have received their food first. I do apologize if this offended you. We have several cooks on and they all work on different items and tables. Our menu changes about every quarter to accommodate seasonal items or remove items that have been discontinued by suppliers, our main staples always stay on though. We apologize for your disappointment but we surely do appreciate you coming in and we hope to see you soon!”
How a similar shop replied to “Boy...I'm really sad to say this, but the service at Iza has just become terrible. My wife…”
“Christian, Thank you for your feedback as we agree that our service is having issues on some nights. We are trying are best to get some new staff up to speed. Please stop in again and pick up a gift card and have a lunch or dinner meal on the house for your honest feedback.”
A generic AI says
“Dear Valued Customer, thank you for taking the time to share your feedback. We sincerely apologize for any inconvenience…”
Model
Qwen3.5-4B (fine-tuned) · 6475 ms · $0.00010 for this reply
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/shots/inbox1.png
[Image: original 2880x1800, displayed at 2000x1250. Multiply coordinates by 1.44 to map to original image.]
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad && cat > shot3.py <<'E'
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
pg = await b.new_page(viewport={"width":1440,"height":900}, device_scale_factor=2)
await pg.goto("http://localhost:3111/better", wait_until="load")
await pg.wait_for_timeout(1500)
secs = pg.locator("section.reading.wide")
print(await secs.count())
tabs = pg.locator(".table-wrap")
print(await tabs.count())
await tabs.nth(0).screenshot(path="shots/eval-main.png")
await secs.nth(0).screenshot(path="shots/eval-bait.png")
await secs.nth(1).screenshot(path="shots/eval-parts.png")
await pg.goto("http://localhost:3111/ab", wait_until="load")
await pg.wait_for_timeout(2000)
await pg.screenshot(path="shots/ab.png")
await b.close()
asyncio.run(main())
E
python3 shot3.py; python3 -c "
import PIL.Image as I,glob
for f in sorted(glob.glob('shots/*.png')): print(f, I.open(f).size)"
2
4
shots/ab.png (2880, 1800)
shots/better-full.png (2880, 7186)
shots/eval-bait.png (1704, 1578)
shots/eval-main.png (1704, 1922)
shots/eval-parts.png (1704, 1752)
shots/inbox0.png (2880, 1800)
shots/inbox1.png (2880, 1800)
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/shots/eval-parts.png
/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/shots/eval-bait.png
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && S=/tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7 && python3 - <<E
import PIL.Image as I
S="$S"
def out(src, dst, box=None, w=None):
im=I.open(src).convert("RGB")
if box: im=im.crop(box)
if w and im.width>w: im=im.resize((w,round(im.height*w/im.width)),I.LANCZOS)
im.save("docs/shots/"+dst, optimize=True); print(dst, im.size)
for n,dst in [(3,"tinker-sessions.png"),(4,"tinker-checkpoints.png"),(5,"tinker-usage.png")]:
im=I.open(f"{S}/images/{n}.png"); print(n, im.size)
out(f"{S}/images/{n}.png", dst, (0,44,im.width,im.height))
sc=S+"/scratchpad/shots/"
out(sc+"inbox1.png","inbox-v4.png",w=1800)
out(sc+"eval-main.png","eval-main.png",w=1600)
out(sc+"eval-bait.png","eval-bait.png",w=1600)
out(sc+"eval-parts.png","eval-parts.png",w=1600)
out(sc+"ab.png","ab-v4.png",w=1800)
E
sed -i 's/Both checks agree, but both are still/Both checks put v4 lowest, but both are still/' app/better/page.tsx; ls -la docs/shots | awk '{print $5,$9}' | tail -8
3 (1698, 1044)
tinker-sessions.png (1698, 1000)
4 (1698, 1044)
tinker-checkpoints.png (1698, 1000)
5 (1698, 1044)
tinker-usage.png (1698, 1000)
inbox-v4.png (1800, 1125)
eval-main.png (1600, 1805)
eval-bait.png (1600, 1482)
eval-parts.png (1600, 1645)
ab-v4.png (1800, 1125)
82342 inbox-phone-full.png
63984 inbox-phone.png
502961 inbox-v4.png
52176 setup-desktop.png
38286 setup-phone.png
179810 tinker-checkpoints.png
100419 tinker-sessions.png
121370 tinker-usage.png
$ grep -n "^:root" -A22 app/globals.css | head -30
17::root {
18- --paper: #F3EDE2; --paper-2: #EBE3D4; --slip: #FBF8F2; --ink: #211C16; --ink-2: #5B5247;
19- --rule: #D3C8B5; --stamp: #B5381F; --stamp-soft: #F1D9CF; --ok: #3F6B3A; --struck: #6F665A;
20- --topbar-h: 58px;
21-}
22-
23-* { box-sizing: border-box; }
24-html { color-scheme: light; }
25-body {
26- background: var(--paper); color: var(--ink);
27- font: 500 15px/1.55 var(--font-manrope), system-ui, sans-serif;
28- -webkit-font-smoothing: antialiased;
29-}
30-h1, h2, h3 { font-family: var(--font-fraunces), Georgia, serif; }
31-button, input, select, textarea { font: inherit; color: inherit; }
32-a { color: inherit; }
33-
34-:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
35-.btn.primary:focus-visible, .topnav a[aria-current]:focus-visible { outline-offset: 3px; }
36-
37-.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
38-
39-/* ---------- top bar ---------- */
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad && cat > cover.html <<'E'
<!doctype html><meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600&family=Manrope:wght@500;700&display=swap" rel="stylesheet">
<style>
:root{--paper:#F3EDE2;--slip:#FBF8F2;--ink:#211C16;--ink2:#5B5247;--stamp:#B5381F;--rule:#D3C8B5}
*{box-sizing:border-box;margin:0}
body{width:1000px;height:420px;background:var(--paper);font-family:Manrope,sans-serif;color:var(--ink);display:grid;grid-template-columns:1fr 400px;gap:36px;padding:44px 52px;overflow:hidden}
h1{font:600 62px/1.02 Fraunces,serif;letter-spacing:-.02em}
.sub{margin-top:18px;font-size:21px;line-height:1.4;color:var(--ink2);max-width:430px}
.tag{margin-top:26px;display:inline-block;border:2px solid var(--stamp);color:var(--stamp);font-weight:700;letter-spacing:.14em;font-size:14px;padding:6px 12px;transform:rotate(-2deg)}
.card{align-self:center;background:var(--slip);border:2px solid var(--ink);padding:22px 24px;transform:rotate(1.2deg);box-shadow:6px 6px 0 var(--rule)}
.stars{color:var(--stamp);letter-spacing:3px;font-size:18px}
.rev{font:400 21px/1.3 Fraunces,serif;margin:8px 0 16px}
.lbl{font-size:12px;font-weight:700;letter-spacing:.12em;color:var(--ink2)}
.rep{margin-top:6px;font-size:16px;line-height:1.45;border-top:1px dashed var(--rule);padding-top:10px}
.stat{display:flex;gap:18px;margin-top:14px;border-top:2px solid var(--ink);padding-top:12px}
.stat b{font:600 30px Fraunces,serif;display:block;line-height:1}
.stat span{font-size:12px;color:var(--ink2)}
</style>
<div><h1>ReplyDesk</h1>
<p class="sub">A 4B model I tuned to answer Google reviews for small shops, and the tests it passed and failed.</p>
<span class="tag">HACKTOBERFEST · SIDE HUSTLE</span></div>
<div class="card"><div class="stars">★☆☆☆☆</div>
<div class="rev">“Charged ₹800 for a check that took five minutes.”</div>
<div class="lbl">DRAFT REPLY</div>
<div class="rep">Ankit, I'm sorry the check felt like too much for five minutes. That is frustrating, and I'd like to hear what you expected.</div>
<div class="stat"><div><b>14%</b><span>promise beyond the facts<br>(long prompt: 31%)</span></div><div><b>$0.0001</b><span>per reply</span></div></div></div>
E
python3 - <<'E'
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
b=await p.chromium.launch(); pg=await b.new_page(viewport={"width":1000,"height":420},device_scale_factor=2)
await pg.goto("file://"+__import__("os").getcwd()+"/cover.html"); await pg.wait_for_timeout(2500)
await pg.screenshot(path="/home/anish-kumar/Code/hacktoberfest/week2/replydesk/docs/shots/cover.png"); await b.close()
asyncio.run(main())
E
Shell cwd was reset to /home/anish-kumar/Code/hacktoberfest
/home/anish-kumar/Code/hacktoberfest/week2/replydesk/docs/shots/cover.png
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && wc -l data/v4/train_chat.jsonl; ls data/v4; ls runs | tail; python3 - <<'E'
import json,collections
rows=[json.loads(l) for l in open('data/v4/train_chat.jsonl')]
print(rows[0].keys() if isinstance(rows[0],dict) else type(rows[0]))
E
cat data/stats.json | head -30; grep -rn "v4" ../README.md | head
2584 data/v4/train_chat.jsonl
gen.log
pilot_gpt-oss-120b,qwen3.6-35b.jsonl
teacher_raw.jsonl
train_chat.jsonl
load_examples.log
smoke
v1
v1.log
v2
v2.log
v3
v3.log
v4
v4.log
dict_keys(['messages'])
{
"raw_pairs": 590054,
"clean_pairs": 50954
}
$ tail -5 runs/v4.log; python3 - <<'E'
import json,collections
raw=[json.loads(l) for l in open('data/v4/teacher_raw.jsonl')]
print(len(raw), raw[0].keys())
c=collections.Counter(r.get('teacher') for r in raw); print(c)
E
grep -c . data/v4/gen.log; tail -5 data/v4/gen.log
│ train_mean_bpb │ 0.280765 │
│ train_mean_nll │ 0.914879 │
└──────────────────────┴───────────┘
tinker_cookbook.checkpoint_utils:467 [[92mINFO[0m] Saved checkpoints: {'state_path': 'tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/weights/final', 'sampler_path': 'tinker://db1be3f5-25a1-5344-9efc-1bc4dc2c76fe:train:0/sampler_weights/final'}
tinker_cookbook.supervised.train:584 [[92mINFO[0m] Training completed successfully
5000 dict_keys(['i', 'k', 'teacher', 'reply', 'facts', 'cost_usd', 'tokens', 'checks'])
Counter({'gpt-oss-120b': 2500, 'qwen3.6-35b': 2500})
18
-> Terri really made your visit wonderful, and I'm thrilled you noticed her fantastic work. It's great to hear you left happy, and we look forward to seeing you again soon.
[1/5 cafe/restaurant] If you go to this place expect a long wait. Minimum is 1 hrs wait for a pizza. I have been 5 times and and always the same. Will removed it from my list of pizzeria choice
facts: []
-> Sorry the wait was so long for your pizza. We know you have been five times and faced this every time. We are looking into our kitchen flow to reduce these delays.
$ cd /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad && sed -i 's/>DRAFT REPLY</>EXAMPLE DRAFT</' cover.html && python3 - <<'E'
import asyncio,os
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
b=await p.chromium.launch(); pg=await b.new_page(viewport={"width":1000,"height":420},device_scale_factor=2)
await pg.goto("file://"+os.getcwd()+"/cover.html"); await pg.wait_for_timeout(2500)
await pg.screenshot(path="/home/anish-kumar/Code/hacktoberfest/week2/replydesk/docs/shots/cover.png"); await b.close()
asyncio.run(main())
E
cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && pkill -f "next dev" ; git add -A . ':!tsconfig.tsbuildinfo' 2>/dev/null; git status --short | head -30
Exit code 144
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && git status --short | head -30; git config user.email
M app/actions.ts
M app/better/page.tsx
M app/globals.css
M lib/backboard.ts
M lib/facts.ts
M lib/types.ts
M ml/scripts/eval4.py
?? docs/shots/ab-v4.png
?? docs/shots/cover.png
?? docs/shots/eval-bait.png
?? docs/shots/eval-main.png
?? docs/shots/eval-parts.png
?? docs/shots/inbox-v4.png
?? docs/shots/tinker-checkpoints.png
?? docs/shots/tinker-sessions.png
?? docs/shots/tinker-usage.png
?? lib/rate-limit.ts
?? ml/scripts/build_bait.py
?? ml/scripts/eval_memory.py
?? ml/scripts/eval_retrieval.py
anishisbusy@gmail.com
$ git add app lib docs ml/scripts && git commit -q -m "Show bait, search and memory results on /better, pick closest 3 Backboard facts, rate-limit drafts, add screenshots and evals" && git log --oneline | head -3; ls -la docs/shots/cover.png
bd24837 Show bait, search and memory results on /better, pick closest 3 Backboard facts, rate-limit drafts, add screenshots and evals
06c7886 Add v4: teacher-written training data with shop facts, rule checks, new eval and blind-test pairs
9765bc9 Fit the blind-test progress bar and nav on phones, add inbox heading for screen readers
-rw-rw-r-- 1 anish-kumar anish-kumar 157766 Oct 17 11:17 docs/shots/cover.png
$ wc -l README.md && cat README.md
138 README.md
# ReplyDesk
Short, warm replies to Google reviews, written in the shop owner's voice by a small open model (Qwen3.5-4B) fine-tuned on real owner replies. Built for the Hacktoberfest 2026 DEV challenge ("Side Hustle").
Live demo: **https://replydesk-shop.vercel.app**

Small shops get reviews every week and most never answer them, because writing a good reply takes time and the replies that chat assistants write sound like a bank. ReplyDesk gives the owner an inbox of reviews, a draft reply for each one, the shop facts that matter for that review, and how they (and owners like them) answered similar reviews before. The owner edits, copies and posts it. Nothing is posted for them.
## What's inside
```mermaid
flowchart LR
B[Browser] -- review text --> E[bge-small in a Web Worker]
B -- server action --> S[Next.js on Vercel]
S -- draft --> T[Tinker: fine-tuned Qwen3.5-4B]
S -- facts for this review --> M[Backboard memory]
S -- promise check --> G[Groq: gpt-oss-20b]
S -- reviews, replies, eval, votes --> P[(Tiger Cloud Postgres)]
E -- query vector --> S
P -- BM25 + DiskANN, fused --> S
```
| Part | Used for |
|---|---|
| **Tinker** | LoRA fine-tune of Qwen3.5-4B, and serving the fine-tuned model through Tinker's OpenAI-compatible endpoint. The eval also runs on Tinker. |
| **Tiger Cloud** | Postgres 18 with `pg_textsearch` (BM25) and `vectorscale` (DiskANN). Holds the shop, reviews, sent replies, 11.5k example replies, eval results and blind-test votes. |
| **Backboard** | One memory assistant per shop. Shop facts ("Free re-check within 7 days") are saved there, and memory search picks the ones that matter for each review. |
| Groq | Checks each draft for promises the shop facts do not back. |
| Next.js 16, React 19, Tailwind 4, drizzle-orm, Phosphor icons, bun | The app. |
## The model
### Data
[Google Local Reviews](https://mcauleylab.ucsd.edu/public_datasets/gdrive/googlelocal/) from the McAuley lab at UCSD. I used four states (Wyoming, Vermont, Montana, Idaho): 590,054 review and owner-reply pairs. The raw data is not in this repo.
`ml/scripts/build_dataset.py` keeps a pair only if:
- the review and reply are in English, not machine-translated, and the reply came within 60 days
- the reply has no phone numbers, emails or links
- the business is not a chain (same name at more than 3 places) and is one of six shop types: auto repair, salon, dentist, cafe/restaurant, home services, retail
- the reply is not a template (the same text used more than twice by one business, or the same opening shared by more than 8 businesses)
- the reply shares at least one content word with the review
That leaves 50,954 pairs. Sign-offs ("Thanks, Bob", "-Ryan, Owner") are stripped because the app adds the owner's name itself. Businesses are split into train, validation and test, so no test business is ever seen in training.
The training set is filtered harder. Replies must be 20+ words, overlap the review by at least two content words, not argue with the customer or call the review false, and for 1 to 3 star reviews, acknowledge the problem in the first lines. That gives 6,000 training pairs (2,262 of them low-star).
### Training
`ml/scripts/train.py` uses `tinker-cookbook` supervised learning: LoRA rank 32, learning rate from the cookbook's helper (about 4.9e-4), batch 64, one epoch, loss on the reply only, thinking turned off in the chat template. The prompt in `ml/replydesk_ml/prompt.py` is the same one the app sends (`lib/prompt.ts`).
It took three tries:
| Run | Change | What went wrong |
|---|---|---|
| v1 | All clean pairs, 2 epochs | Learned real owners' bad habits too: "Cameron, you are obviously confused. We did not see you in the store." |
| v2 | Dropped defensive and stock replies | Polite, but bland. Many replies could have gone under any review. |
| v3 | Kept only replies that are long enough and talk about what the reviewer said | The one the app uses. Test loss 2.125. |
v3 trained on 1.28M tokens for about $0.95. All three runs together came to about $4 of Tinker credit.
### Eval
`ml/scripts/eval.py` writes a reply to 300 held-out reviews (half of them 1 to 3 stars) with four systems, then a judge from a different model family (gpt-oss-120b on Tinker) sees the four replies in random order with no labels. It marks each one and ranks them.
| | Qwen3.5-4B prompted | **ReplyDesk (4B fine-tuned)** | Qwen3.8-27B prompted | Real owner |
|---|---|---|---|---|
| Median words | 60.5 | **34** | 56 | 34 |
| Stock phrases ("we value your feedback") | 19.3% | **2.0%** | 9.7% | 7.3% |
| Specific promises (refund, call-back, discount) | 18.0% | **11.3%** | 20.3% | 12.7% |
| Mentions a detail from the review | 98.3% | **56.3%** | 98.3% | 55.3% |
| Reads like a template (judge) | 22.7% | **77.0%** | 17.0% | 63.0% |
| Judge's first pick | 40.3% | **5.0%** | 44.0% | 10.7% |
| Cost per reply | $0.00013 | **$0.00010** | not public | |
| Median latency | 0.95 s | **1.0 s** | 1.16 s | |
What this says, plainly:
- Fine-tuning made the model write like real owners. Length, stock phrases and made-up promises all moved from where the prompted model sits to where real owners sit, and stock phrases dropped below even real owners.
- The judge does not like that. It prefers the prompted models, whose replies are longer and nearly always repeat a detail back. It ranks real owners' replies near the bottom too (first pick 10.7%). So the judge rewards polish, and polish is not the same as what an owner would post.
- The fine-tuned model is a bit cheaper per reply because it writes less, and it costs nothing like a closed model.
Whether owners prefer it is a question for owners, so the app has a blind test (`/ab`): two replies to the same review, one from each 4B model, sides shuffled, and you pick the one you would send. Votes go to Tiger and show up on `/better`.
To run it again: `uv run python scripts/eval.py gen`, then `judge`, then `report` (from `ml/`). Generations and judgements are cached.
## Running the app
Needs [bun](https://bun.sh) 1.4+ and Node 22+.
```bash
bun install
cp .env.example .env.local # optional, the app runs without it
bun dev # http://localhost:3000
bun run build && bun run lint
```
With no env vars the app runs offline: sample reviews, an in-memory store, and a mock reply instead of the model. Each service switches on by itself when its variable is set, and the app falls back when a call fails.
| Variable | What it does |
|---|---|
| `TINKER_MODEL_PATH` | `tinker://...` path of the fine-tuned model. Unset means mock replies. |
| `TINKER_API_KEY` | Tinker key. |
| `TINKER_BASE_URL` | Defaults to Tinker's OpenAI-compatible endpoint. |
| `DATABASE_URL` | Postgres (Tiger Cloud). Unset means the in-memory store. Apply `db/schema.sql` first, then `bun --env-file=.env.local scripts/seed.ts` for the demo shop. |
| `BACKBOARD_API_KEY` | Backboard memory for shop facts. Unset means every saved fact is used. |
| `GROQ_API_KEY` | Groq, for the promise check. Unset means a simple keyword check. |
The training side lives in `ml/` and uses [uv](https://docs.astral.sh/uv/): `extract.py` and `build_dataset.py` (data), `to_chat.py` and `train.py` (Tinker), `load_examples.py` (example replies into Tiger), `eval.py`.
## How a draft is made
1. **Setup.** The owner picks a shop type (one of the six the model was trained on), adds shop facts and, if they like, past replies. Facts are also saved to Backboard memory (one assistant per shop, only new facts are added and removed ones deleted). Past replies go into `reply_examples`.
2. **Draft.** The fine-tuned model writes the reply with exactly the training prompt. Thinking is off (`reasoning_effort: false`). The app adds the sign-off.
3. **Facts for this review.** Backboard memory search returns the facts that match the review. If Backboard fails, all saved facts are used.
4. **Similar replies.** Shown beside the draft, never sent to the model. One SQL statement on Tiger: BM25 (`pg_textsearch`) and DiskANN (`vectorscale`) each give their top 20 for the shop's type, and reciprocal rank fusion (k = 60) merges them. The owner's own past replies come first. The review is embedded in the browser (`@huggingface/transformers`, `Xenova/bge-small-en-v1.5`, in a Web Worker), so there is no embedding server. Until the model has loaded, the search uses BM25 only.
5. **Promise check.** Groq (`openai/gpt-oss-20b`) lists every promise in the reply and marks whether the facts back it. Anything not backed turns the stamp to "Check". If Groq fails, a keyword check runs instead.
Where things live: `lib/prompt.ts`, `lib/model.ts` (draft), `lib/backboard.ts` and `lib/facts.ts` (memory), `lib/guardrail.ts` (promise check), `lib/embed-worker.ts` and `lib/use-embedder.ts` (browser embeddings), `lib/data.ts` (one switch between `data-mock.ts` and `data-db.ts`), `db/schema.sql` (tables), `app/actions.ts` (server actions).
## Limits
- Training data is from four US states. Reviews in Hindi or Hinglish are not handled well yet.
- Replies are not posted to Google. The Google Business Profile API needs approval, so the owner copies the reply.
- The judge is a model, not a person. The blind test is the check that matters.
- TypeScript is pinned to 6.0.3 because `typescript-eslint` does not load on TS 7 yet.
## Credits
Dataset: Google Local Reviews, McAuley lab, UCSD. Please cite:
- Jiacheng Li, Jingbo Shang, Julian McAuley. *UCTopic: Unsupervised Contrastive Learning for Phrase Representations and Topic Mining.* ACL 2022.
- An Yan, Zhankui He, Jiacheng Li, Tianyang Zhang, Julian McAuley. *Personalized Showcases: Generating Multi-Modal Explanations for Recommendations.* SIGIR 2023.
MIT license.
$ python3 - <<'E'
s=open('README.md').read()
a=s.index('It took three tries:')
b=s.index('## Running the app')
new='''It took four tries:
| Run | Change | What went wrong |
|---|---|---|
| v1 | All clean pairs, 2 epochs | Learned real owners' bad habits too: "Cameron, you are obviously confused. We did not see you in the store." |
| v2 | Dropped defensive and stock replies | Polite, but bland. Many replies could have gone under any review. |
| v3 | Kept only replies that are long enough and talk about what the reviewer said | Wrote like an owner, but a real owner picked the long-prompt model's reply 15 times out of 19 in a blind test. Only 59% of its replies mention something the customer said. |
| **v4** | **Teacher data, shop facts in the prompt, rule filter** | **The one the app uses.** |
#### v4: a rulebook turned into training data
The long-prompt model (same Qwen3.5-4B with every rule spelled out) wrote good replies, but it needs a 348 token prompt on every call. v4 teaches those habits to the model itself, so the prompt can be half the size.
1. `ml/scripts/teacher.py` asks two larger open models (gpt-oss-120b and Qwen3.6-35B-A3B on Tinker) for replies to 2,500 training reviews, 2 candidates each, 5,000 in all. The prompt has the style guide and three real owner replies of the same trade and star bucket.
2. Half the reviews get 1 to 3 "shop facts" (like the facts a real owner saves in Setup), and the teacher is told to promise only what the facts say.
3. Free code checks (`ml/replydesk_ml/rules.py`) throw out replies that are too long or short, skip what the reviewer said, use stock phrases, argue, sign off, or promise a refund, discount, free item or call-back that the facts do not back. Openings are capped so the data is not 300 copies of "I'm sorry to hear".
4. Real owner replies that pass the same checks are mixed in, up to half as many as the teacher replies. That gives 2,584 training rows.
5. Same training recipe as v3 (LoRA rank 32), 2 epochs. The training prompt is the app's prompt, plus the shop facts block when the shop has facts.
### Eval
`ml/scripts/eval4.py` writes replies to the 300 held-out reviews with five systems and scores them. Half of the reviews come with shop facts, the way the app sends them.
| | short prompt | long prompt | v3 | **v4** | real owner |
|---|---|---|---|---|---|
| Passes every rule | 27% | 87% | 38% | **87%** | 18% |
| Mentions a detail | 90% | 94% | 59% | **91%** | 51% |
| Promises beyond the facts (reviews with facts) | 20% | 1% | 11% | **0%** | 10% |
| Prompt tokens per call | 167 | 348 | 167 | **167** | |
| Cost per reply | $0.00014 | $0.00015 | $0.00010 | **$0.00010** | |
| Judge: v4 wins head to head (100 reviews) | 52% | 55% | 81% | | 76% |
The rules were written from the same guide that made the training data, so the rule rows flatter v4. The judge (gpt-oss-120b, a different model family) is the independent row: v4 clearly beats v3 and real owners, and ties the long prompt and the short prompt.
#### The hard test: reviews that ask for money
`ml/scripts/build_bait.py` picks 150 real 1 and 2 star reviews that demand a refund, a freebie or a call-back, from businesses that are not in train, validation or test.
| | short prompt | long prompt | v3 | **v4** | real owner |
|---|---|---|---|---|---|
| Passes every rule | 5% | 49% | 21% | **69%** | 7% |
| Promises beyond the facts (code check) | 49% | 31% | 43% | **14%** | 25% |
| Promises beyond the facts (gpt-oss-120b reads each reply) | 36% | 29% | 43% | **15%** | 25% |
| Judge: v4 wins head to head (100 reviews) | 63% | 79% | 84% | | 82% |
This is the one place v4 clearly beats the long prompt: it promises beyond the facts about half as often, and the judge picks it 79 times out of 100.
#### The parts around the model
`ml/scripts/eval_retrieval.py` (Tiger search): for 120 held-out reviews, the share of the top 3 past reviews a judge marked as the same kind of situation. BM25 only 65%, vector only 71%, both fused 72%. Fusing is best, but only by a point over vectors, which is inside the noise.
`ml/scripts/eval_memory.py` (Backboard memory): 120 reviews, 46 of which need a saved shop fact. Sending every fact finds 100% of the needed ones but 25% of what is sent is useful. Memory's closest 3 finds 89% and 44% of what is sent is useful, with half as many facts. This test also found a bug: Backboard's search returns every memory up to the limit in the order they were saved, with a score that is a distance, so the first version of the app was sending almost all facts. It now sorts by score and keeps the closest 3.
#### People
The app has a blind test (`/ab`): two replies to the same review, sides shuffled, and you pick the one you would send. Votes go to Tiger and show up on `/better`. For v3 against the short prompt, a real shop owner picked v3 in 4 of 19 saved votes. The blind test now compares v4 against the long-prompt model.
To run the evals again, from `ml/`: `uv run python scripts/eval4.py gen`, then `report`, `judge`, `promise` (use `EVAL_SET=bait` for the hard test). Generations and judgements are cached.
'''
s=s[:a]+new+s[b:]
s=s.replace('fine-tuned on real owner replies.','fine-tuned with the help of larger open models and real owner replies.',1)
s=s.replace('docs/shots/inbox-desktop.png','docs/shots/inbox-v4.png',1)
s=s.replace('Backboard memory search returns the facts that match the review.','Backboard memory search returns the closest 3 facts to the review (sorted by score, since the API returns them in saved order).')
s=s.replace("so no test business is ever seen in training.\n\nThe training set is filtered harder.","so no test business is ever seen in training.\n\nThe v3 training set (6,000 pairs) was filtered harder than that.",1)
s=s.replace('(`eval.py`)','`eval4.py`, `eval_retrieval.py`, `eval_memory.py`')
open('README.md','w').write(s)
E
grep -n "eval.py\|3 tries\|three tries\|v3 trained" README.md
104:`ml/scripts/eval_retrieval.py` (Tiger search): for 120 held-out reviews, the share of the top 3 past reviews a judge marked as the same kind of situation. BM25 only 65%, vector only 71%, both fused 72%. Fusing is best, but only by a point over vectors, which is inside the noise.
136:The training side lives in `ml/` and uses [uv](https://docs.astral.sh/uv/): `extract.py` and `build_dataset.py` (data), `to_chat.py` and `train.py` (Tinker), `load_examples.py` (example replies into Tiger), `eval.py`.
$ sed -i '136s/`eval.py`\./`eval4.py`, `eval_retrieval.py`, `eval_memory.py` (evals)./' README.md && sed -n 136p README.md | tail -c 120
` (Tinker), `load_examples.py` (example replies into Tiger), `eval4.py`, `eval_retrieval.py`, `eval_memory.py` (evals).
/home/anish-kumar/Code/hacktoberfest/week2/POST.md
The file /home/anish-kumar/Code/hacktoberfest/week2/POST.md has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /home/anish-kumar/Code/hacktoberfest/week2 && python3 - <<'E'
s=open('POST.md').read()
a=s.index('<!--'); b=s.index('-->')+4
todo=s[a:b]
s=s[b:].lstrip('\n')
s=s.replace('under a angry refund','under an angry refund')
s=s.replace('About $3.80 of that was the three first runs and their evals, and the rest was v4 (teacher data, training, tests).','About $4 of that was the first three runs and their evals, and the rest was v4 (teacher data, training, tests).')
open('POST.md','w').write(s)
open('POST_TODO.md','w').write("# Before publishing POST.md\n\n"+"""1. Push the repo so the image links work (`docs/shots/*.png` on main).
2. Paste the DevRelay session embed or link where POST.md says so in "My Agent Session" (also what makes the Entire category real).
3. Update the blind-test numbers in "My First User" if new /ab votes came in.
4. Cover image: `cover_image` already points at docs/shots/cover.png (1000x420).
5. Set `published: true` or press Publish in the editor. Submissions close Mon Oct 19, 12:29 PM IST.
""")
E
head -8 POST.md
---
title: "ReplyDesk: a 4B model that answers Google reviews and stops promising refunds it can't give"
published: false
cover_image: https://raw.githubusercontent.com/Sarcastic-Soul/replydesk/main/docs/shots/cover.png
tags: devchallenge, hf26challenge, ai, opensource
---
*This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle](https://dev.to/challenges/hacktoberfest-week2-2026-10-12)*
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && git add README.md && git commit -q -m "Update README for v4, the bait test, and the search and memory evals" && git log --oneline | head -3 && git remote -v | head -1 && git status -sb | head -2
ea27c31 Update README for v4, the bait test, and the search and memory evals
bd24837 Show bait, search and memory results on /better, pick closest 3 Backboard facts, rate-limit drafts, add screenshots and evals
06c7886 Add v4: teacher-written training data with shop facts, rule checks, new eval and blind-test pairs
origin https://github.com/Sarcastic-Soul/replydesk.git (fetch)
## main...origin/main [ahead 3]
Judging criteria are confirmed from the live page, and the post, screenshots and backup-category evals are drafted. The three commits are not pushed yet, so nothing is live.
Judging criteria (confirmed from the page):
1. Writing quality, weighted most heavily.
2. Relevance to the theme.
3. Creativity.
4. Technical execution.
5. Use of partner tech, only if you enter a category.
Ties go to the post with the most reactions. The agent session and the "My First User" section are optional but encouraged.
Tinker dashboard screenshots: yes, use them. They show real spend ($7.70, four models, four training runs) and cost almost nothing to add. I cropped out the browser bar and put them in docs/shots/. The post uses the usage and sessions pages. I left the checkpoints one out.
Backup categories:
- Tiger Data: I tested the search on 120 held-out reviews. Fused search finds relevant results 72% of the time, vector alone 71% and BM25 alone 65%. The gain over vector is inside the noise, and the post says so.
- Backboard: the test found a bug in the app. Search returns every fact in saved order, and the score is a distance, so we were sending almost all facts. It now keeps the closest 3. Memory's closest 3 finds 89% of the facts a reply needs, while sending half as many (3.0 vs 6.2).
- Entire: needs your DevRelay agent session. I can't create it.
- Bait results: now in Tiger and on /better.
- Rate limit: draft requests are now limited per visitor so the public demo can't drain the Tinker credit.
Files:
- week2/POST.md is rewritten and published as false. It has a cover image, the video embed, the Tinker, eval and bait screenshots, and the v4 story.
- It keeps the honest parts: the v3 loss and the tie with the long prompt.
- week2/POST_TODO.md lists what's left before publishing.
- README.md is updated for v4.
Needs you:
1. Push OK. git push sends the 3 local commits (06c7886, bd24837, ea27c31). The post's image links only work after the push, and Vercel probably redeploys on push, which also ships the Backboard fix and rate limit. Say go and I'll push.
2. DevRelay session. Save this session with it and paste the link into "My Agent Session". Tell me if you don't know how to do that.
3. Demo shop data. The demo is Pratik's cafe, but the sample reviews in the inbox are garage-themed ("car was ready a day late"). That looks odd in screenshots and in the demo. I didn't touch the live data. Want me to swap in cafe reviews?
4. Blind-test votes. The /ab page now compares v4 against the long prompt, and it has no votes yet. Ask Pratik and a few others to vote. If votes arrive, I'll update the "My First User" section.
yes push thes changes to github, i can cretate devrealy , last time i told you to do it, you failed misrabely, just give me the json file fo conversation to upload,please pick one type fo dat for demo , either garacge one or the cafe data, if required you can rerecord the video
$ git push origin main 2>&1 | tail -3; sed -n 1,80p scripts/seed.ts; set -a; . .env.local; set +a; psql "$DATABASE_URL" -c "select r.id, author, rating, left(text,60) t, source, created_at, status from reviews r order by created_at desc" -c "select * from shop_facts" -c "select id,name,category,city,owner_name from shops" 2>&1 | head -40
To https://github.com/Sarcastic-Soul/replydesk.git
9765bc9..ea27c31 main -> main
// Puts the demo shop and its sample reviews into Postgres. Safe to run again.
// Run: bun --env-file=.env.local scripts/seed.ts
import postgres from "postgres";
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
const sql = postgres(url, { ssl: "require", max: 1 });
const H = 3600_000;
const REVIEWS = [
{ author: "Rohan M.", rating: 2, text: "Car was ready a day late and nobody called to tell me. Work itself was fine, brakes feel good now, but I had to take leave twice.", age: 2 * H },
{ author: "Ankit P.", rating: 1, text: "Charged ₹800 for a check that took five minutes.", age: 26 * H },
{ author: "Meera K.", rating: 4, text: "Good service, the waiting area could be cleaner.", age: 28 * H },
{ author: "Priya S.", rating: 5, text: "Ramesh bhai fixed the AC in under an hour. Honest price, showed me the old part.", age: 72 * H, reply: "Thank you Priya, I'll tell Ramesh. See you next time. — Ganesh" },
{ author: "David L.", rating: 5, text: "Got me back on the road on a Sunday. Lifesavers.", age: 96 * H, reply: "Glad we could help, David. Drive safe. — Ganesh" },
];
const FACTS = [
"Same-day WhatsApp update when a job is delayed",
"Ask for Ramesh at the front desk",
"Free re-check within 7 days",
];
let [shop] = await sql`select id from shops order by created_at limit 1`;
if (!shop) {
[shop] = await sql`insert into shops (name, category, city, owner_name)
values ('Shree Ganesh Auto Garage', 'auto repair', 'Kothrud, Pune', 'Ganesh') returning id`;
await sql`insert into shop_facts ${sql(FACTS.map((text, position) => ({ shop_id: shop.id, text, position })))}`;
console.log("created demo shop");
} else {
console.log("a shop already exists, leaving it as is");
}
let added = 0;
for (const r of REVIEWS) {
const [have] = await sql`select id from reviews where shop_id = ${shop.id} and author = ${r.author} and text = ${r.text}`;
if (have) continue;
const [rev] = await sql`insert into reviews (shop_id, author, rating, text, source, created_at, status)
values (${shop.id}, ${r.author}, ${r.rating}, ${r.text}, 'sample', ${new Date(Date.now() - r.age)}, ${r.reply ? "replied" : "needs_reply"})
returning id`;
if (r.reply) {
await sql`insert into replies (review_id, text, sent_at) values (${rev.id}, ${r.reply}, ${new Date(Date.now() - r.age + H)})`;
}
added++;
}
console.log(`added ${added} sample reviews`);
await sql.end();
(eval):.:1: no such file or directory: .env.local
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
Is the server running locally and accepting connections on that socket?
$ pwd; ls -a | grep env; ls -a .. | grep env; ls -a ../ml | grep env
/home/anish-kumar/Code/hacktoberfest/week2/replydesk
.env.example
.env.local
next-env.d.ts
.env
ls: cannot access '../ml': No such file or directory
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk/ml && set -a; . /home/anish-kumar/Code/hacktoberfest/week2/replydesk/.env.local; set +a; uv run python - <<'E'
import os,psycopg
with psycopg.connect(os.environ["DATABASE_URL"]) as c, c.cursor() as cur:
for q in ["select id,name,category,city,owner_name,backboard_assistant_id from shops","select shop_id,text,position from shop_facts order by position","select id,author,rating,left(text,50),source,status,created_at from reviews order by created_at desc","select count(*) from replies","select count(*) from reply_examples where shop_id is not null"]:
cur.execute(q); print(q); [print(" ",r) for r in cur.fetchall()]
E
select id,name,category,city,owner_name,backboard_assistant_id from shops
(UUID('376de0c9-a1d7-4804-ad9e-f039a501ab0a'), 'HOP-In Café', 'cafe/restaurant', 'Naya Raipur', 'Pratik', '55a37681-187f-4817-a7c4-181565702eb9')
select shop_id,text,position from shop_facts order by position
(UUID('376de0c9-a1d7-4804-ad9e-f039a501ab0a'), 'open from 12:30 to 1am', 0)
(UUID('376de0c9-a1d7-4804-ad9e-f039a501ab0a'), 'Mostly serves college students as near IIIT Naya Raipur college', 1)
(UUID('376de0c9-a1d7-4804-ad9e-f039a501ab0a'), 'offer Veg and non-veg Momos, Pizzas, grilled chicken, pastries, and various food items.', 2)
select id,author,rating,left(text,50),source,status,created_at from reviews order by created_at desc
(UUID('a598ad32-be57-4840-a166-f2a4afafb2cc'), 'Jyoti Prajapati', 4, 'Small bakery inside the campus.\nThey will offer Ve', 'paste', 'replied', datetime.datetime(2026, 10, 16, 10, 34, 20, 869674, tzinfo=zoneinfo.ZoneInfo(key='Etc/UTC')))
(UUID('51b3a09f-7a23-4bb6-afac-45c42d59af33'), 'Rohan M.', 2, 'Car was ready a day late and nobody called to tell', 'sample', 'needs_reply', datetime.datetime(2026, 10, 13, 4, 32, 26, 778000, tzinfo=zoneinfo.ZoneInfo(key='Etc/UTC')))
(UUID('0e4f4f25-4b23-4703-b5df-1455f1c37ea4'), 'Ankit P.', 1, 'Charged ₹800 for a check that took five minutes.', 'sample', 'needs_reply', datetime.datetime(2026, 10, 12, 4, 32, 27, 489000, tzinfo=zoneinfo.ZoneInfo(key='Etc/UTC')))
(UUID('4ed0b7e7-c149-42b0-bcf5-21cb51c770fd'), 'Meera K.', 4, 'Good service, the waiting area could be cleaner.', 'sample', 'needs_reply', datetime.datetime(2026, 10, 12, 2, 32, 27, 977000, tzinfo=zoneinfo.ZoneInfo(key='Etc/UTC')))
(UUID('6c914d2f-9a15-4ce0-a47c-9525f80a78b4'), 'Priya S.', 5, 'Ramesh bhai fixed the AC in under an hour. Honest ', 'sample', 'replied', datetime.datetime(2026, 10, 10, 6, 32, 28, 450000, tzinfo=zoneinfo.ZoneInfo(key='Etc/UTC')))
(UUID('385705ca-79cd-4094-a21d-6e5d35f486c9'), 'David L.', 5, 'Got me back on the road on a Sunday. Lifesavers.', 'sample', 'replied', datetime.datetime(2026, 10, 9, 6, 32, 29, 399000, tzinfo=zoneinfo.ZoneInfo(key='Etc/UTC')))
select count(*) from replies
(3,)
select count(*) from reply_examples where shop_id is not null
(3,)
$ python3 - <<'E'
import json,random
rows=[json.loads(l) for l in open('data/test.jsonl')]
c=[r for r in rows if r['shop_type']=='cafe/restaurant' and 10<=len(r['review'].split())<=40 and 'http' not in r['review']]
random.Random(3).shuffle(c)
for r in c[:40]: print(r['rating'], r['author'], '|', r['business'], '|', r['review'].replace('\n',' '))
E
1 Beth Montandon | North End Pizza | They wouldn’t even make my son a root beer float
3 Margo Crandall | Sizzler | Very hard to get around in the restaurant. The food was good, but getting to and from the salad bar was difficult and had difficulty getting just one glove out.
5 George Rietkerk | The Cactus Grill | Excellent service joyful waitress and she wasn't bad to look at either
5 Rich Burgess | PK Coffee, LLC | Beautiful, small coffee shop with a great staff and excellent coffee and treats. Very comfy setting with locally sourced products and vegan options as well.
2 Pamela Doyle | Nunyuns Bakery & Cafe | The lady behind the counter was very rude. First and last visit.
5 TJ Edwards | The Curb Bar and Grill | I absolutely love the atmosphere, staff, and food. I always get the PB Burger. It sounds weird, but you gotta try it!
5 Jessica Forte | Switchback Brewing Co. | Great brewery with a brewery pup! Great beer with great variety and great merchandise to purchase. Such a cute puppy!
3 Mary McKee | Montana Brewing Company | Food was alright. Service was not good. We had the enchilada bites which tasted like something out of a freezer bag but the calienté burger was good. Beer was local and good. We wouldn't be back but don't regret going.
5 Ryan Manning | Lefty's Bar & Grill | Best burger in town. Best place to get your drink on before heading uptown... It's the Leftorium, what else it's there to say?
1 Vanessa Romero | Mi Linda Michoacana Paleteria & Neveria Ice Cream & Popsicle | My husband was in line for 30 minutes and they didn’t take his order even the people that went in after him got there stuff made !!! Favoritism I say
5 Michael Griffith | Positive Pie Hardwick | Really great pizza, atmosphere was awesome. The staff was amazing with all positive vibes. Highly recommend! Cheers 🍻
2 DERREK BAILEY | Steve's Hot Dogs | Service was ok but I could microwave the same quality of hot dog at home. Nothing special better hot dogs at costco by far.
2 Dick JOHNSON | Millers Country Store | More hype it's an Amish store might as well make your own sandwich
1 Dan Tuck | Noxon Mercantile & Cafe | The food was under cooked and the "chef" was a complete arrogant tool.
5 Charlie Teall | Guang Zhou Restaurant | The best Chinese food I've found yet in Boise. Large portions, each dish tasted great. Kind of a clumsy online ordering scheme, but worth it. Excellent!
5 Johnny Goldfuss | Accomplice Beer Company | Stopped in for a late lunch and abrew, the Krimson King Ember Lager was delicious, as was the Italian sandwich and kale salad! I know, who'd have thought kale could taste good right? It was excellent!
1 Michael Beach | Smokin' Mikes | They are closed. I have dismissed this already. Now it's back. Turn off your stuff if you are closed.
5 Cade Moments | Toro Viejo | Great portions, friendly staff and great authentic Mexican food. My family and I love this place!!!
2 Troy | Smoky Mountain Pizzeria Grill | $10 buffet for semi fresh pizza and smallest salad bar in SW Idaho.
5 Lee Collins | Big Eddy's | Great place to eat Thompson Falls, Montana. We would definitely be back next time we are in town.
1 Alessandra Julian | Tacos El Rey | My family and I were there a few weeks ago, worst service ever and they weren’t even busy. We were completely ignored by the whole staff. Food was okay!
1 Dan Finch | Vaqueros | This place used to be really good. I ordered a pick up order. The food was soggy. The taco shells were stale. The meat was dry. Just not very good. I won't be back after that
2 Jason Peterson | Commons 1882 | Bad experience. Here for lunch. Service is non existent. Rather than a sound track, you get to listen to local radio (read loud advertisements) blasted throughout the place. Makes it difficult to have a conversation. Food is mediocre.
1 Derrick Rolandson | Glacier Perks Coffee House | A 45+ minute wait on only 3 sandwiches! Also 2 of the sandwiches were supposed to be grilled to perfection and not one of them were grilled or hot! Waisted so much time!
4 Brian Kliewer | El Comedor Mexican Restaurant | Fluffy tacos are still the bomb!! And fat Albertos!! 🤘👌
1 Paul Lande | Panera Bread | Very disappointed, waited in line and ordered four loaves of bread the lady at the window had her mask pulled all the way down no gloves touching the cash register and then went to retrieve our bread, NO THANKS!!!!!
3 David Carson | Jeremiah Johnson Brewing Company CDA Pub | Nothing special about it. Just a nice little place to get a drink. Same food as the other bar it replaced.
2 bader alshammari | Granny's Gourmet Donuts | Granny or that old guy was so mean with me I do not know why ! I asked him about the price per donut and if they serve coffee he answered after a while of being looking at me careless!
5 rita ellis | The Firehouse Grill | I just ate here for the 1st time I had the widow maker burger It is literally to die for I can't wait to eat the other 1/2
5 renee portrey | The Nova Café | Just a great quirky place for an interestingly unique breakfast downtown Bozman! Super great servers and fun atmosphere!
1 Tom | Peking Chinese Restaurant | I went there to eat during normal business hours, but they weren't open. The place looked closed down.
5 Auston Hetland | Nampa Pizza Restaurant | Smoky Mountain Pizzeria & Grill | There food is so good and they have gluten free pizza and pasta
4 Justin Hieter | Firenza Pizza | Good pizza, the cheesy bread sticks were also good, friendly staff, overall chill atmosphere, would return in the near future for another bite to eat
3 lisa robertson | Tien's Place Oriental AND American Dining | Long wait, wrong order and egg rolls were not cooked.
5 Luke Bernier | Riceworks | Great food and great service every time I go in. Favorite restaurant in kuna!
5 Jeffery Johann | Das Alpenhaus Delikatessen LLC | Fresh genuine items ithe grocery section store owner oversees everything. Pricy, but some things are worth it.
3 Nacole Phillips | The Lift | Food was good. The beer was warm in a cold glass
5 Joshua Davis | Beverly's | Even though it's been almost 10 years since I ate here, there are two things I remember. The amazing view of the lake, and the lobster corn dogs (sadly I don't see the on the menu anymore).
1 Promyce Aulger | Buckin' Coffee | Always super ignorant when I try to pick up my doordash orders and snobby
5 Kristina Walker | Grains Of Montana | Nice staff. Counter ordering and then food delivered to tables. Local specials rotate on menu. Parking lot is smaller than seating. So at peak times be prepared to wait for parking.
$ cd /home/anish-kumar/Code/hacktoberfest/week2/replydesk && python3 - <<'E'
p='scripts/seed.ts'
s=open(p).read()
a=s.index('const REVIEWS = [')
b=s.index('let [shop]')
new='''// Real reviews from the public Google Local Reviews dataset (other businesses), shortened to first name and initial.
const REVIEWS = [
{ author: "Derrick R.", rating: 1, text: "A 45+ minute wait on only 3 sandwiches! Also 2 of the sandwiches were supposed to be grilled to perfection and not one of them were grilled or hot! Wasted so much time!", age: 2 * H },
{ author: "Vanessa R.", rating: 1, text: "My husband was in line for 30 minutes and they didn't take his order even the people that went in after him got their stuff made. Favoritism I say.", age: 26 * H },
{ author: "Pamela D.", rating: 2, text: "The lady behind the counter was very rude. First and last visit.", age: 30 * H },
{ author: "Justin H.", rating: 4, text: "Good pizza, the cheesy bread sticks were also good, friendly staff, overall chill atmosphere, would return in the near future for another bite to eat.", age: 72 * H },
{ author: "Rich B.", rating: 5, text: "Beautiful, small coffee shop with a great staff and excellent coffee and treats. Very comfy setting with vegan options as well.", age: 96 * H },
];
const FACTS = [
"Open from 12:30 pm to 1 am",
"Mostly serves college students from the campus next door",
"Veg and non-veg momos, pizzas, grilled chicken and pastries",
];
'''
s=s[:a]+new+s[b:]
s=s.replace("values ('Shree Ganesh Auto Garage', 'auto repair', 'Kothrud, Pune', 'Ganesh')","values ('HOP-In Café', 'cafe/restaurant', 'Naya Raipur', 'Pratik')")
open(p,'w').write(s)
E
cd ml && set -a; . /home/anish-kumar/Code/hacktoberfest/week2/replydesk/.env.local; set +a; uv run python - <<'E'
import os,psycopg
with psycopg.connect(os.environ["DATABASE_URL"]) as c, c.cursor() as cur:
cur.execute("delete from replies where review_id in (select id from reviews where source='sample')")
cur.execute("delete from reviews where source='sample'")
print(cur.rowcount,"old sample reviews removed")
E
cd .. && bun --env-file=.env.local scripts/seed.ts
5 old sample reviews removed
a shop already exists, leaving it as is
added 5 sample reviews
$ (nohup bun run dev -p 3111 > /tmp/claude-1000/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7/scratchpad/dev.log 2>&1 &); sleep 7; curl -s -o /dev/null -w "%{http_code}\n" localhost:3111/; J=/home/anish-kumar/.claude-work/projects/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7.jsonl; ls -la $J; set -a; . ./.env.local; set +a; python3 - <<E
import os,re
t=open("$J").read()
for k in ["TINKER_API_KEY","GROQ_API_KEY","DATABASE_URL","BACKBOARD_API_KEY"]:
v=os.environ.get(k,"")
print(k,len(v), t.count(v) if v else 0)
print(len(re.findall(r"tml-[A-Za-z0-9]{20,}",t)), len(re.findall(r"gsk_[A-Za-z0-9]{20,}",t)), len(re.findall(r"postgres(?:ql)?://[^\s\"']+",t)))
E
200
-rw------- 1 anish-kumar anish-kumar 11836603 Oct 17 11:23 /home/anish-kumar/.claude-work/projects/-home-anish-kumar-Code-hacktoberfest/03ff413c-9ca1-4bb3-bd04-359b2a5f81c7.jsonl
TINKER_API_KEY 73 2
GROQ_API_KEY 56 0
DATABASE_URL 113 0
BACKBOARD_API_KEY 48 0
2 0 0