Joseph Antolick Claude Opus 4.8 commited on
Commit ·
aa00509
1
Parent(s): af9809a
Rename Crankycheck -> TemperCheck
Browse filesPackage crankycheck/ -> tempercheck/, CrankyVerdict -> TemperVerdict,
CRANKY_* env vars -> TEMPER_*, plus all UI copy, prompt branding, README
Space card, and docs. Behavior unchanged; tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CLAUDE.md +8 -8
- README.md +13 -13
- app.py +12 -12
- pyproject.toml +2 -2
- requirements.txt +1 -1
- {crankycheck → tempercheck}/__init__.py +3 -3
- {crankycheck → tempercheck}/inference.py +7 -7
- {crankycheck → tempercheck}/prompt.py +11 -11
- tests/test_parsing.py +1 -1
- uv.lock +25 -25
CLAUDE.md
CHANGED
|
@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
| 4 |
|
| 5 |
## What this is
|
| 6 |
|
| 7 |
-
**
|
| 8 |
|
| 9 |
This is an image-in → score/verdict-out vision task. The pipeline is: **profile image → small Gemma 4 E4B vision model → structured JSON verdict → Gradio UI**.
|
| 10 |
|
| 11 |
-
> **Status:** scaffolded
|
| 12 |
|
| 13 |
## Hard hackathon constraints (these gate eligibility — do not violate)
|
| 14 |
|
|
@@ -18,25 +18,25 @@ This is an image-in → score/verdict-out vision task. The pipeline is: **profil
|
|
| 18 |
- **Deadline: June 15, 2026** (hack window June 5–15). This is a time-boxed hackathon project — prefer the simplest thing that works end-to-end over architectural polish.
|
| 19 |
|
| 20 |
### Optional bonus "merit badges" (worth steering toward when cheap)
|
| 21 |
-
- **Off-Grid** — no cloud APIs; run the model locally / on-Space.
|
| 22 |
- **Well-Tuned** — fine-tuned model. **Custom UI** — bespoke Gradio styling. **Llama.cpp** — GGUF inference path. **Agent traces** shared. **Field notes** documentation.
|
| 23 |
|
| 24 |
## Environment & commands (this machine)
|
| 25 |
|
| 26 |
Python is **`uv`-only** on this workstation (no system Python; `pip`/`conda` not installed). Use:
|
| 27 |
- `uv run app.py` — run the Gradio app locally (NOT `python app.py`). Defaults to the **Ollama** backend on **port 7140**.
|
| 28 |
-
- `
|
| 29 |
- `uv run pytest` — run the parsing tests. Single test: `uv run pytest tests/test_parsing.py::test_clean_json`.
|
| 30 |
- `uv add <pkg>` / `uv add --dev <pkg>` — add a runtime / dev dependency.
|
| 31 |
|
| 32 |
GPU: local **RTX 5090, 32 GB VRAM (sm_120 / Blackwell)** — the E4B model (~8B) fits trivially. The HF Space runs on smaller hardware; E4B is sized for that. Don't bump to a larger Gemma 4 (12B/26B/31B) without checking the target Space tier.
|
| 33 |
|
| 34 |
### Backend selection (the key seam)
|
| 35 |
-
`
|
| 36 |
defaults to **`transformers` on a Space** (detected via the `SPACE_ID` env) and
|
| 37 |
**`ollama` locally** — so no manual config is needed in either place.
|
| 38 |
- **`transformers`** (the deployed Space — this is where verdicts are real) — loads `google/gemma-4-E4B-it` at module level onto `cuda` and runs generation inside a `@spaces.GPU` function (ZeroGPU). `requirements.txt` carries `spaces`/torch≥2.8/transformers; local dev does not install them.
|
| 39 |
-
- **`ollama`** (local only) — POSTs to `127.0.0.1:11434`. ⚠️ **Local Gemma 4 vision is broken** in the current Ollama (0.30.7): the abliterated model hallucinates instead of reading images, and the official `gemma4:e4b` returns a blank. So the Ollama path is only good for **UI/plumbing work** — it cannot produce a real
|
| 40 |
|
| 41 |
### ZeroGPU specifics (the Space)
|
| 42 |
- Hardware **ZeroGPU** is set in the Space *settings* (not the README). Default size `large` = 48 GB, ample for E4B.
|
|
@@ -44,11 +44,11 @@ defaults to **`transformers` on a Space** (detected via the `SPACE_ID` env) and
|
|
| 44 |
- `google/gemma-4-E4B-it` is **gated** — the Space needs an `HF_TOKEN` secret whose account has accepted the Gemma license, or model download fails at boot.
|
| 45 |
|
| 46 |
### Port
|
| 47 |
-
This app binds **7140** (Gradio default 7860 is triple-booked on this machine). Override with `
|
| 48 |
|
| 49 |
## Architecture notes (load-bearing decisions)
|
| 50 |
|
| 51 |
-
- **The VLM lives behind `
|
| 52 |
- **The output contract is JSON, parsed defensively.** `prompt.py` asks for `{score, verdict, rationale, signals}`; `parse_verdict` extracts the first balanced JSON object and clamps/falls back on every field so a malformed small-VLM response never raises. If you change the JSON shape, update `parse_verdict`, the UI rendering in `app.py`, and `tests/test_parsing.py` together — that's the riskiest seam and the reason the tests exist (they run with no model).
|
| 53 |
- **The transformers model loads once** at import (module-level, per the ZeroGPU rule), built by `_build_transformers_scorer()`; the returned `@spaces.GPU` scorer is reused per request.
|
| 54 |
- **Gemma 4 multimodal expects the image before the text** in the message content (see `build_messages`).
|
|
|
|
| 4 |
|
| 5 |
## What this is
|
| 6 |
|
| 7 |
+
**TemperCheck** — a Gradio app that takes an image or screenshot of a social-media profile and estimates how short-tempered / difficult that person looks to deal with. Built for the Hugging Face **Build Small Hackathon** (`https://huggingface.co/build-small-hackathon/`).
|
| 8 |
|
| 9 |
This is an image-in → score/verdict-out vision task. The pipeline is: **profile image → small Gemma 4 E4B vision model → structured JSON verdict → Gradio UI**.
|
| 10 |
|
| 11 |
+
> **Status:** scaffolded; ZeroGPU Space deploy is the next step. `app.py` (Gradio UI) → `tempercheck/inference.py` (swappable backend) → `tempercheck/prompt.py` (prompt + defensive JSON parsing). Parsing tests pass. ⚠️ The vision path has NOT been validated yet — local Ollama vision is broken (see below), so the real read only works once deployed to the Space.
|
| 12 |
|
| 13 |
## Hard hackathon constraints (these gate eligibility — do not violate)
|
| 14 |
|
|
|
|
| 18 |
- **Deadline: June 15, 2026** (hack window June 5–15). This is a time-boxed hackathon project — prefer the simplest thing that works end-to-end over architectural polish.
|
| 19 |
|
| 20 |
### Optional bonus "merit badges" (worth steering toward when cheap)
|
| 21 |
+
- **Off-Grid** — no cloud APIs; run the model locally / on-Space. TemperCheck should aim for this (local VLM inference) since it's a strong fit.
|
| 22 |
- **Well-Tuned** — fine-tuned model. **Custom UI** — bespoke Gradio styling. **Llama.cpp** — GGUF inference path. **Agent traces** shared. **Field notes** documentation.
|
| 23 |
|
| 24 |
## Environment & commands (this machine)
|
| 25 |
|
| 26 |
Python is **`uv`-only** on this workstation (no system Python; `pip`/`conda` not installed). Use:
|
| 27 |
- `uv run app.py` — run the Gradio app locally (NOT `python app.py`). Defaults to the **Ollama** backend on **port 7140**.
|
| 28 |
+
- `TEMPER_BACKEND=transformers uv run app.py` — run the same backend the HF Space uses (`google/gemma-4-E4B-it` via `transformers`). Needs torch/transformers/spaces installed locally (not part of the default local setup).
|
| 29 |
- `uv run pytest` — run the parsing tests. Single test: `uv run pytest tests/test_parsing.py::test_clean_json`.
|
| 30 |
- `uv add <pkg>` / `uv add --dev <pkg>` — add a runtime / dev dependency.
|
| 31 |
|
| 32 |
GPU: local **RTX 5090, 32 GB VRAM (sm_120 / Blackwell)** — the E4B model (~8B) fits trivially. The HF Space runs on smaller hardware; E4B is sized for that. Don't bump to a larger Gemma 4 (12B/26B/31B) without checking the target Space tier.
|
| 33 |
|
| 34 |
### Backend selection (the key seam)
|
| 35 |
+
`TEMPER_BACKEND` switches the model path; the UI is identical either way. It
|
| 36 |
defaults to **`transformers` on a Space** (detected via the `SPACE_ID` env) and
|
| 37 |
**`ollama` locally** — so no manual config is needed in either place.
|
| 38 |
- **`transformers`** (the deployed Space — this is where verdicts are real) — loads `google/gemma-4-E4B-it` at module level onto `cuda` and runs generation inside a `@spaces.GPU` function (ZeroGPU). `requirements.txt` carries `spaces`/torch≥2.8/transformers; local dev does not install them.
|
| 39 |
+
- **`ollama`** (local only) — POSTs to `127.0.0.1:11434`. ⚠️ **Local Gemma 4 vision is broken** in the current Ollama (0.30.7): the abliterated model hallucinates instead of reading images, and the official `gemma4:e4b` returns a blank. So the Ollama path is only good for **UI/plumbing work** — it cannot produce a real temper read. Real verdicts require the Space. (See `memory/abliterated-gemma4-vision-broken.md`.)
|
| 40 |
|
| 41 |
### ZeroGPU specifics (the Space)
|
| 42 |
- Hardware **ZeroGPU** is set in the Space *settings* (not the README). Default size `large` = 48 GB, ample for E4B.
|
|
|
|
| 44 |
- `google/gemma-4-E4B-it` is **gated** — the Space needs an `HF_TOKEN` secret whose account has accepted the Gemma license, or model download fails at boot.
|
| 45 |
|
| 46 |
### Port
|
| 47 |
+
This app binds **7140** (Gradio default 7860 is triple-booked on this machine). Override with `TEMPER_PORT`. Already registered in the global port list.
|
| 48 |
|
| 49 |
## Architecture notes (load-bearing decisions)
|
| 50 |
|
| 51 |
+
- **The VLM lives behind `tempercheck/inference.py`.** The rest of the app only calls `score_image(pil_image) -> TemperVerdict` and never imports a backend directly. This is what lets the Ollama ↔ transformers swap (and a future llama.cpp/GGUF path for the Llama.cpp badge) happen without touching the UI.
|
| 52 |
- **The output contract is JSON, parsed defensively.** `prompt.py` asks for `{score, verdict, rationale, signals}`; `parse_verdict` extracts the first balanced JSON object and clamps/falls back on every field so a malformed small-VLM response never raises. If you change the JSON shape, update `parse_verdict`, the UI rendering in `app.py`, and `tests/test_parsing.py` together — that's the riskiest seam and the reason the tests exist (they run with no model).
|
| 53 |
- **The transformers model loads once** at import (module-level, per the ZeroGPU rule), built by `_build_transformers_scorer()`; the returned `@spaces.GPU` scorer is reused per request.
|
| 54 |
- **Gemma 4 multimodal expects the image before the text** in the message content (see `build_messages`).
|
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 😤
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: red
|
|
@@ -7,20 +7,20 @@ sdk: gradio
|
|
| 7 |
sdk_version: "6.18.0"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
short_description: How
|
| 11 |
---
|
| 12 |
|
| 13 |
-
# 😤
|
| 14 |
|
| 15 |
-
Upload a social-media profile (or a screenshot of one) and
|
| 16 |
-
playful read on **how
|
| 17 |
-
punchy verdict, and the "signals" it picked up on.
|
| 18 |
|
| 19 |
Built for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon/)
|
| 20 |
using a small **Gemma 4 E4B** vision-language model (~8B params, well under the
|
| 21 |
32B limit).
|
| 22 |
|
| 23 |
-
> ⚠️ **It's a party game.**
|
| 24 |
> not a real personality test and makes no factual claim about any real person.
|
| 25 |
|
| 26 |
## How it runs
|
|
@@ -39,11 +39,11 @@ so the local path is for UI work — real verdicts come from the Space.
|
|
| 39 |
|
| 40 |
| Var | Default | Purpose |
|
| 41 |
|-----|---------|---------|
|
| 42 |
-
| `
|
| 43 |
-
| `
|
| 44 |
-
| `
|
| 45 |
| `OLLAMA_HOST` | `http://127.0.0.1:11434` | local Ollama server |
|
| 46 |
-
| `
|
| 47 |
|
| 48 |
## Tests
|
| 49 |
|
|
@@ -54,6 +54,6 @@ uv run pytest
|
|
| 54 |
## Project layout
|
| 55 |
|
| 56 |
- `app.py` — Gradio UI.
|
| 57 |
-
- `
|
| 58 |
-
- `
|
| 59 |
- `tests/test_parsing.py` — output-parsing tests (no model needed).
|
|
|
|
| 1 |
---
|
| 2 |
+
title: TemperCheck
|
| 3 |
emoji: 😤
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: red
|
|
|
|
| 7 |
sdk_version: "6.18.0"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
short_description: How short a temper does this social-media profile look to have?
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# 😤 TemperCheck
|
| 14 |
|
| 15 |
+
Upload a social-media profile (or a screenshot of one) and TemperCheck gives a
|
| 16 |
+
playful read on **how short-tempered / difficult that person looks to deal with**
|
| 17 |
+
— a 0–100 score, a punchy verdict, and the "signals" it picked up on.
|
| 18 |
|
| 19 |
Built for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon/)
|
| 20 |
using a small **Gemma 4 E4B** vision-language model (~8B params, well under the
|
| 21 |
32B limit).
|
| 22 |
|
| 23 |
+
> ⚠️ **It's a party game.** TemperCheck reads vibes in a picture for laughs. It is
|
| 24 |
> not a real personality test and makes no factual claim about any real person.
|
| 25 |
|
| 26 |
## How it runs
|
|
|
|
| 39 |
|
| 40 |
| Var | Default | Purpose |
|
| 41 |
|-----|---------|---------|
|
| 42 |
+
| `TEMPER_BACKEND` | `transformers` on a Space, else `ollama` | force a backend |
|
| 43 |
+
| `TEMPER_HF_MODEL` | `google/gemma-4-E4B-it` | transformers model id |
|
| 44 |
+
| `TEMPER_OLLAMA_MODEL` | `huihui_ai/gemma-4-abliterated:e4b-q8_0` | local Ollama model id |
|
| 45 |
| `OLLAMA_HOST` | `http://127.0.0.1:11434` | local Ollama server |
|
| 46 |
+
| `TEMPER_PORT` | `7140` | local Gradio port |
|
| 47 |
|
| 48 |
## Tests
|
| 49 |
|
|
|
|
| 54 |
## Project layout
|
| 55 |
|
| 56 |
- `app.py` — Gradio UI.
|
| 57 |
+
- `tempercheck/inference.py` — backend abstraction (Ollama ↔ transformers).
|
| 58 |
+
- `tempercheck/prompt.py` — system prompt + defensive JSON parsing.
|
| 59 |
- `tests/test_parsing.py` — output-parsing tests (no model needed).
|
app.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Upload a social-media profile screenshot; get a playful "
|
| 4 |
small Gemma 4 vision model. For the Build Small Hackathon (model <= 32B, Gradio
|
| 5 |
on Hugging Face Spaces).
|
| 6 |
|
| 7 |
Run locally (Ollama backend, default): uv run app.py
|
| 8 |
-
Run the Spaces backend locally:
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
@@ -14,9 +14,9 @@ import os
|
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
|
| 17 |
-
from
|
| 18 |
|
| 19 |
-
PORT = int(os.environ.get("
|
| 20 |
|
| 21 |
# Bind real Ctrl-V: Gradio's "clipboard" source only adds a click-to-paste
|
| 22 |
# button, so we listen for the browser paste event ourselves and feed the
|
|
@@ -26,7 +26,7 @@ PORT = int(os.environ.get("CRANKY_PORT", "7140"))
|
|
| 26 |
PASTE_JS = """
|
| 27 |
() => {
|
| 28 |
const inject = (blob) => {
|
| 29 |
-
const root = document.querySelector('#
|
| 30 |
if (!root) return;
|
| 31 |
let input = root.querySelector("input[type='file']");
|
| 32 |
if (!input) {
|
|
@@ -56,7 +56,7 @@ PASTE_JS = """
|
|
| 56 |
"""
|
| 57 |
|
| 58 |
DISCLAIMER = (
|
| 59 |
-
"🎈 *
|
| 60 |
"laughs — it is **not** a real personality test and says nothing factual "
|
| 61 |
"about any actual person.*"
|
| 62 |
)
|
|
@@ -67,7 +67,7 @@ def _render(verdict) -> tuple[str, str]:
|
|
| 67 |
signals = " ".join(f"`{s}`" for s in verdict.signals) if verdict.signals else "—"
|
| 68 |
md = (
|
| 69 |
f"## {verdict.band}\n\n"
|
| 70 |
-
f"**{verdict.verdict}** —
|
| 71 |
f"`{bar}`\n\n"
|
| 72 |
f"{verdict.rationale}\n\n"
|
| 73 |
f"**Signals:** {signals}"
|
|
@@ -87,8 +87,8 @@ def analyze(image):
|
|
| 87 |
)
|
| 88 |
|
| 89 |
|
| 90 |
-
with gr.Blocks(title="
|
| 91 |
-
gr.Markdown("# 😤
|
| 92 |
gr.Markdown(DISCLAIMER)
|
| 93 |
with gr.Row():
|
| 94 |
with gr.Column():
|
|
@@ -96,9 +96,9 @@ with gr.Blocks(title="Crankycheck") as demo:
|
|
| 96 |
type="pil",
|
| 97 |
label="Profile image / screenshot",
|
| 98 |
sources=["upload", "clipboard"],
|
| 99 |
-
elem_id="
|
| 100 |
)
|
| 101 |
-
go = gr.Button("Check the
|
| 102 |
with gr.Column():
|
| 103 |
result = gr.Markdown(label="Verdict")
|
| 104 |
with gr.Accordion("Raw model output (agent trace)", open=False):
|
|
|
|
| 1 |
+
"""TemperCheck — Gradio app.
|
| 2 |
|
| 3 |
+
Upload a social-media profile screenshot; get a playful "temper" read from a
|
| 4 |
small Gemma 4 vision model. For the Build Small Hackathon (model <= 32B, Gradio
|
| 5 |
on Hugging Face Spaces).
|
| 6 |
|
| 7 |
Run locally (Ollama backend, default): uv run app.py
|
| 8 |
+
Run the Spaces backend locally: TEMPER_BACKEND=transformers uv run app.py
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
|
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
|
| 17 |
+
from tempercheck import get_backend_name, score_image
|
| 18 |
|
| 19 |
+
PORT = int(os.environ.get("TEMPER_PORT", "7140"))
|
| 20 |
|
| 21 |
# Bind real Ctrl-V: Gradio's "clipboard" source only adds a click-to-paste
|
| 22 |
# button, so we listen for the browser paste event ourselves and feed the
|
|
|
|
| 26 |
PASTE_JS = """
|
| 27 |
() => {
|
| 28 |
const inject = (blob) => {
|
| 29 |
+
const root = document.querySelector('#temper_image');
|
| 30 |
if (!root) return;
|
| 31 |
let input = root.querySelector("input[type='file']");
|
| 32 |
if (!input) {
|
|
|
|
| 56 |
"""
|
| 57 |
|
| 58 |
DISCLAIMER = (
|
| 59 |
+
"🎈 *TemperCheck is a party-game novelty. It reads vibes in a picture for "
|
| 60 |
"laughs — it is **not** a real personality test and says nothing factual "
|
| 61 |
"about any actual person.*"
|
| 62 |
)
|
|
|
|
| 67 |
signals = " ".join(f"`{s}`" for s in verdict.signals) if verdict.signals else "—"
|
| 68 |
md = (
|
| 69 |
f"## {verdict.band}\n\n"
|
| 70 |
+
f"**{verdict.verdict}** — temper **{verdict.score}/100**\n\n"
|
| 71 |
f"`{bar}`\n\n"
|
| 72 |
f"{verdict.rationale}\n\n"
|
| 73 |
f"**Signals:** {signals}"
|
|
|
|
| 87 |
)
|
| 88 |
|
| 89 |
|
| 90 |
+
with gr.Blocks(title="TemperCheck") as demo:
|
| 91 |
+
gr.Markdown("# 😤 TemperCheck\n### How short a temper does this profile look to have?")
|
| 92 |
gr.Markdown(DISCLAIMER)
|
| 93 |
with gr.Row():
|
| 94 |
with gr.Column():
|
|
|
|
| 96 |
type="pil",
|
| 97 |
label="Profile image / screenshot",
|
| 98 |
sources=["upload", "clipboard"],
|
| 99 |
+
elem_id="temper_image",
|
| 100 |
)
|
| 101 |
+
go = gr.Button("Check the temper ☕", variant="primary")
|
| 102 |
with gr.Column():
|
| 103 |
result = gr.Markdown(label="Verdict")
|
| 104 |
with gr.Accordion("Raw model output (agent trace)", open=False):
|
pyproject.toml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
[project]
|
| 2 |
-
name = "
|
| 3 |
version = "0.1.0"
|
| 4 |
-
description = "Estimate how
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12"
|
| 7 |
dependencies = [
|
|
|
|
| 1 |
[project]
|
| 2 |
+
name = "tempercheck"
|
| 3 |
version = "0.1.0"
|
| 4 |
+
description = "Estimate how short-tempered a social-media profile looks, from an image (Gemma 4 E4B)."
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12"
|
| 7 |
dependencies = [
|
requirements.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# Dependencies for the Hugging Face Space (ZeroGPU, transformers backend).
|
| 2 |
-
# On a Space,
|
| 3 |
# is set), so no env var is required. The Space also needs an HF_TOKEN secret
|
| 4 |
# with access to the gated google/gemma-4-E4B-it repo.
|
| 5 |
spaces
|
|
|
|
| 1 |
# Dependencies for the Hugging Face Space (ZeroGPU, transformers backend).
|
| 2 |
+
# On a Space, TEMPER_BACKEND defaults to "transformers" automatically (SPACE_ID
|
| 3 |
# is set), so no env var is required. The Space also needs an HF_TOKEN secret
|
| 4 |
# with access to the gated google/gemma-4-E4B-it repo.
|
| 5 |
spaces
|
{crankycheck → tempercheck}/__init__.py
RENAMED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
from .prompt import
|
| 4 |
from .inference import score_image, get_backend_name
|
| 5 |
|
| 6 |
__all__ = [
|
| 7 |
-
"
|
| 8 |
"build_messages",
|
| 9 |
"parse_verdict",
|
| 10 |
"score_image",
|
|
|
|
| 1 |
+
"""TemperCheck — estimate how short-tempered a social-media profile looks, from an image."""
|
| 2 |
|
| 3 |
+
from .prompt import TemperVerdict, build_messages, parse_verdict
|
| 4 |
from .inference import score_image, get_backend_name
|
| 5 |
|
| 6 |
__all__ = [
|
| 7 |
+
"TemperVerdict",
|
| 8 |
"build_messages",
|
| 9 |
"parse_verdict",
|
| 10 |
"score_image",
|
{crankycheck → tempercheck}/inference.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
"""Inference layer for
|
| 2 |
|
| 3 |
-
Two interchangeable backends, selected by the
|
| 4 |
defaults to "transformers" on a Hugging Face Space, "ollama" elsewhere):
|
| 5 |
|
| 6 |
- "transformers" (the Hugging Face Space / ZeroGPU) — loads google/gemma-4-E4B-it
|
|
@@ -26,7 +26,7 @@ from PIL import Image
|
|
| 26 |
from .prompt import (
|
| 27 |
SYSTEM_PROMPT,
|
| 28 |
USER_INSTRUCTION,
|
| 29 |
-
|
| 30 |
build_messages,
|
| 31 |
parse_verdict,
|
| 32 |
)
|
|
@@ -34,7 +34,7 @@ from .prompt import (
|
|
| 34 |
# On a Space, default to the transformers backend; locally, default to Ollama.
|
| 35 |
_ON_SPACE = bool(os.environ.get("SPACE_ID"))
|
| 36 |
BACKEND = os.environ.get(
|
| 37 |
-
"
|
| 38 |
).lower()
|
| 39 |
|
| 40 |
# Use 127.0.0.1, not "localhost": on Windows the latter resolves to IPv6 ::1
|
|
@@ -42,9 +42,9 @@ BACKEND = os.environ.get(
|
|
| 42 |
# was over half the total latency).
|
| 43 |
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
|
| 44 |
OLLAMA_MODEL = os.environ.get(
|
| 45 |
-
"
|
| 46 |
)
|
| 47 |
-
HF_MODEL = os.environ.get("
|
| 48 |
|
| 49 |
# The verdict JSON is ~80 tokens; cap generation so the model can't ramble.
|
| 50 |
# Headroom over that keeps the JSON from ever truncating (which would break
|
|
@@ -162,7 +162,7 @@ else:
|
|
| 162 |
# --- Public API -------------------------------------------------------------
|
| 163 |
|
| 164 |
|
| 165 |
-
def score_image(image: Image.Image) ->
|
| 166 |
"""Run the configured backend on a PIL image and return a parsed verdict."""
|
| 167 |
if image is None:
|
| 168 |
raise ValueError("No image provided.")
|
|
|
|
| 1 |
+
"""Inference layer for TemperCheck.
|
| 2 |
|
| 3 |
+
Two interchangeable backends, selected by the TEMPER_BACKEND env var (which
|
| 4 |
defaults to "transformers" on a Hugging Face Space, "ollama" elsewhere):
|
| 5 |
|
| 6 |
- "transformers" (the Hugging Face Space / ZeroGPU) — loads google/gemma-4-E4B-it
|
|
|
|
| 26 |
from .prompt import (
|
| 27 |
SYSTEM_PROMPT,
|
| 28 |
USER_INSTRUCTION,
|
| 29 |
+
TemperVerdict,
|
| 30 |
build_messages,
|
| 31 |
parse_verdict,
|
| 32 |
)
|
|
|
|
| 34 |
# On a Space, default to the transformers backend; locally, default to Ollama.
|
| 35 |
_ON_SPACE = bool(os.environ.get("SPACE_ID"))
|
| 36 |
BACKEND = os.environ.get(
|
| 37 |
+
"TEMPER_BACKEND", "transformers" if _ON_SPACE else "ollama"
|
| 38 |
).lower()
|
| 39 |
|
| 40 |
# Use 127.0.0.1, not "localhost": on Windows the latter resolves to IPv6 ::1
|
|
|
|
| 42 |
# was over half the total latency).
|
| 43 |
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434")
|
| 44 |
OLLAMA_MODEL = os.environ.get(
|
| 45 |
+
"TEMPER_OLLAMA_MODEL", "huihui_ai/gemma-4-abliterated:e4b-q8_0"
|
| 46 |
)
|
| 47 |
+
HF_MODEL = os.environ.get("TEMPER_HF_MODEL", "google/gemma-4-E4B-it")
|
| 48 |
|
| 49 |
# The verdict JSON is ~80 tokens; cap generation so the model can't ramble.
|
| 50 |
# Headroom over that keeps the JSON from ever truncating (which would break
|
|
|
|
| 162 |
# --- Public API -------------------------------------------------------------
|
| 163 |
|
| 164 |
|
| 165 |
+
def score_image(image: Image.Image) -> TemperVerdict:
|
| 166 |
"""Run the configured backend on a PIL image and return a parsed verdict."""
|
| 167 |
if image is None:
|
| 168 |
raise ValueError("No image provided.")
|
{crankycheck → tempercheck}/prompt.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Prompt construction and structured-output parsing for
|
| 2 |
|
| 3 |
The model is asked to return a single JSON object. Small VLMs drift from
|
| 4 |
requested formats, so `parse_verdict` is deliberately defensive: it extracts the
|
|
@@ -15,10 +15,10 @@ from typing import Any
|
|
| 15 |
# The whole app hangs off this contract. If you change the shape, update
|
| 16 |
# parse_verdict, the Gradio output rendering, and tests/test_parsing.py together.
|
| 17 |
SYSTEM_PROMPT = """\
|
| 18 |
-
You are
|
| 19 |
screenshot of a social-media profile, you estimate — for entertainment only — how
|
| 20 |
-
"cranky to deal with" the person seems. This is a whimsical
|
| 21 |
-
real personality assessment or a factual claim about anyone.
|
| 22 |
|
| 23 |
Judge mainly by what the person CHOSE TO PUT in their profile (bio text, display
|
| 24 |
name, handle, captions, stated attitude); treat facial expression and overall
|
|
@@ -28,7 +28,7 @@ vibe as a lighter, secondary signal. Read the profile text carefully and look fo
|
|
| 28 |
tone; insults; mocking others; "I don't care what you think" energy.
|
| 29 |
2. PRIDE IN CONFLICT — does the profile brag about upsetting people, starting
|
| 30 |
fights, blocking/roasting/"destroying" others, or being "brutally honest"?
|
| 31 |
-
Treating confrontation as a personality is a strong
|
| 32 |
3. EXPLICIT WARNING SIGNS stated right in the profile — slurs, hateful or
|
| 33 |
demeaning language, open anger, ALL-CAPS ranting, relentless negativity or
|
| 34 |
doom, or harsh criticism aimed at other people.
|
|
@@ -46,7 +46,7 @@ Score guide (0 = delightful, 100 = run):
|
|
| 46 |
|
| 47 |
Respond with ONLY a single JSON object, no prose before or after, of the form:
|
| 48 |
{
|
| 49 |
-
"score": <integer 0-100, higher = crankier>,
|
| 50 |
"verdict": "<3-6 word punchy label>",
|
| 51 |
"rationale": "<1-2 sentences naming the specific signals you actually saw>",
|
| 52 |
"signals": ["<short flag>", "<short flag>", "<short flag>"]
|
|
@@ -58,7 +58,7 @@ mocking how someone looks."""
|
|
| 58 |
|
| 59 |
USER_INSTRUCTION = (
|
| 60 |
"Read this profile — its bio/handle text first, then the overall vibe — and "
|
| 61 |
-
"return the
|
| 62 |
)
|
| 63 |
|
| 64 |
# 0-100 score bucket -> emoji label used by the UI.
|
|
@@ -72,7 +72,7 @@ SCORE_BANDS = [
|
|
| 72 |
|
| 73 |
|
| 74 |
@dataclass
|
| 75 |
-
class
|
| 76 |
score: int
|
| 77 |
verdict: str
|
| 78 |
rationale: str
|
|
@@ -120,8 +120,8 @@ def _first_json_object(text: str) -> dict | None:
|
|
| 120 |
return None
|
| 121 |
|
| 122 |
|
| 123 |
-
def parse_verdict(text: str) ->
|
| 124 |
-
"""Parse model output into a
|
| 125 |
data = _first_json_object(text) or {}
|
| 126 |
|
| 127 |
# score: accept int/float/str, clamp to 0-100, default mid on failure.
|
|
@@ -138,7 +138,7 @@ def parse_verdict(text: str) -> CrankyVerdict:
|
|
| 138 |
signals = [str(signals)]
|
| 139 |
signals = [str(s).strip() for s in signals if str(s).strip()][:5]
|
| 140 |
|
| 141 |
-
return
|
| 142 |
score=score,
|
| 143 |
verdict=str(data.get("verdict", "Inscrutable")).strip() or "Inscrutable",
|
| 144 |
rationale=str(data.get("rationale", "The model kept its cards close."))
|
|
|
|
| 1 |
+
"""Prompt construction and structured-output parsing for TemperCheck.
|
| 2 |
|
| 3 |
The model is asked to return a single JSON object. Small VLMs drift from
|
| 4 |
requested formats, so `parse_verdict` is deliberately defensive: it extracts the
|
|
|
|
| 15 |
# The whole app hangs off this contract. If you change the shape, update
|
| 16 |
# parse_verdict, the Gradio output rendering, and tests/test_parsing.py together.
|
| 17 |
SYSTEM_PROMPT = """\
|
| 18 |
+
You are TemperCheck, a playful but sharp-eyed party-game judge. Given a photo or
|
| 19 |
screenshot of a social-media profile, you estimate — for entertainment only — how
|
| 20 |
+
short-tempered / "cranky to deal with" the person seems. This is a whimsical
|
| 21 |
+
novelty read, NOT a real personality assessment or a factual claim about anyone.
|
| 22 |
|
| 23 |
Judge mainly by what the person CHOSE TO PUT in their profile (bio text, display
|
| 24 |
name, handle, captions, stated attitude); treat facial expression and overall
|
|
|
|
| 28 |
tone; insults; mocking others; "I don't care what you think" energy.
|
| 29 |
2. PRIDE IN CONFLICT — does the profile brag about upsetting people, starting
|
| 30 |
fights, blocking/roasting/"destroying" others, or being "brutally honest"?
|
| 31 |
+
Treating confrontation as a personality is a strong temper signal.
|
| 32 |
3. EXPLICIT WARNING SIGNS stated right in the profile — slurs, hateful or
|
| 33 |
demeaning language, open anger, ALL-CAPS ranting, relentless negativity or
|
| 34 |
doom, or harsh criticism aimed at other people.
|
|
|
|
| 46 |
|
| 47 |
Respond with ONLY a single JSON object, no prose before or after, of the form:
|
| 48 |
{
|
| 49 |
+
"score": <integer 0-100, higher = shorter-tempered / crankier>,
|
| 50 |
"verdict": "<3-6 word punchy label>",
|
| 51 |
"rationale": "<1-2 sentences naming the specific signals you actually saw>",
|
| 52 |
"signals": ["<short flag>", "<short flag>", "<short flag>"]
|
|
|
|
| 58 |
|
| 59 |
USER_INSTRUCTION = (
|
| 60 |
"Read this profile — its bio/handle text first, then the overall vibe — and "
|
| 61 |
+
"return the temper JSON. Be playful, not mean."
|
| 62 |
)
|
| 63 |
|
| 64 |
# 0-100 score bucket -> emoji label used by the UI.
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
@dataclass
|
| 75 |
+
class TemperVerdict:
|
| 76 |
score: int
|
| 77 |
verdict: str
|
| 78 |
rationale: str
|
|
|
|
| 120 |
return None
|
| 121 |
|
| 122 |
|
| 123 |
+
def parse_verdict(text: str) -> TemperVerdict:
|
| 124 |
+
"""Parse model output into a TemperVerdict, never raising on bad output."""
|
| 125 |
data = _first_json_object(text) or {}
|
| 126 |
|
| 127 |
# score: accept int/float/str, clamp to 0-100, default mid on failure.
|
|
|
|
| 138 |
signals = [str(signals)]
|
| 139 |
signals = [str(s).strip() for s in signals if str(s).strip()][:5]
|
| 140 |
|
| 141 |
+
return TemperVerdict(
|
| 142 |
score=score,
|
| 143 |
verdict=str(data.get("verdict", "Inscrutable")).strip() or "Inscrutable",
|
| 144 |
rationale=str(data.get("rationale", "The model kept its cards close."))
|
tests/test_parsing.py
CHANGED
|
@@ -4,7 +4,7 @@ small VLM, so it's the part worth testing without a model in the loop.
|
|
| 4 |
Run: uv run pytest (single test: uv run pytest tests/test_parsing.py::test_clean_json)
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
from
|
| 8 |
|
| 9 |
|
| 10 |
def test_clean_json():
|
|
|
|
| 4 |
Run: uv run pytest (single test: uv run pytest tests/test_parsing.py::test_clean_json)
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
from tempercheck.prompt import parse_verdict
|
| 8 |
|
| 9 |
|
| 10 |
def test_clean_json():
|
uv.lock
CHANGED
|
@@ -241,31 +241,6 @@ wheels = [
|
|
| 241 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 242 |
]
|
| 243 |
|
| 244 |
-
[[package]]
|
| 245 |
-
name = "crankycheck"
|
| 246 |
-
version = "0.1.0"
|
| 247 |
-
source = { virtual = "." }
|
| 248 |
-
dependencies = [
|
| 249 |
-
{ name = "gradio" },
|
| 250 |
-
{ name = "pillow" },
|
| 251 |
-
{ name = "requests" },
|
| 252 |
-
]
|
| 253 |
-
|
| 254 |
-
[package.dev-dependencies]
|
| 255 |
-
dev = [
|
| 256 |
-
{ name = "pytest" },
|
| 257 |
-
]
|
| 258 |
-
|
| 259 |
-
[package.metadata]
|
| 260 |
-
requires-dist = [
|
| 261 |
-
{ name = "gradio", specifier = ">=6.18.0" },
|
| 262 |
-
{ name = "pillow", specifier = ">=12.2.0" },
|
| 263 |
-
{ name = "requests", specifier = ">=2.34.2" },
|
| 264 |
-
]
|
| 265 |
-
|
| 266 |
-
[package.metadata.requires-dev]
|
| 267 |
-
dev = [{ name = "pytest", specifier = ">=9.1.0" }]
|
| 268 |
-
|
| 269 |
[[package]]
|
| 270 |
name = "fastapi"
|
| 271 |
version = "0.137.1"
|
|
@@ -1114,6 +1089,31 @@ wheels = [
|
|
| 1114 |
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
| 1115 |
]
|
| 1116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1117 |
[[package]]
|
| 1118 |
name = "tomlkit"
|
| 1119 |
version = "0.14.0"
|
|
|
|
| 241 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 242 |
]
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
[[package]]
|
| 245 |
name = "fastapi"
|
| 246 |
version = "0.137.1"
|
|
|
|
| 1089 |
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
| 1090 |
]
|
| 1091 |
|
| 1092 |
+
[[package]]
|
| 1093 |
+
name = "tempercheck"
|
| 1094 |
+
version = "0.1.0"
|
| 1095 |
+
source = { virtual = "." }
|
| 1096 |
+
dependencies = [
|
| 1097 |
+
{ name = "gradio" },
|
| 1098 |
+
{ name = "pillow" },
|
| 1099 |
+
{ name = "requests" },
|
| 1100 |
+
]
|
| 1101 |
+
|
| 1102 |
+
[package.dev-dependencies]
|
| 1103 |
+
dev = [
|
| 1104 |
+
{ name = "pytest" },
|
| 1105 |
+
]
|
| 1106 |
+
|
| 1107 |
+
[package.metadata]
|
| 1108 |
+
requires-dist = [
|
| 1109 |
+
{ name = "gradio", specifier = ">=6.18.0" },
|
| 1110 |
+
{ name = "pillow", specifier = ">=12.2.0" },
|
| 1111 |
+
{ name = "requests", specifier = ">=2.34.2" },
|
| 1112 |
+
]
|
| 1113 |
+
|
| 1114 |
+
[package.metadata.requires-dev]
|
| 1115 |
+
dev = [{ name = "pytest", specifier = ">=9.1.0" }]
|
| 1116 |
+
|
| 1117 |
[[package]]
|
| 1118 |
name = "tomlkit"
|
| 1119 |
version = "0.14.0"
|