Joseph Antolick Claude Opus 4.8 commited on
Commit
aa00509
·
1 Parent(s): af9809a

Rename Crankycheck -> TemperCheck

Browse files

Package 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 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
- **Crankycheck** — a Gradio app that takes an image or screenshot of a social-media profile and estimates how likely that person is to be cranky 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 and working end-to-end. `app.py` (Gradio UI) → `crankycheck/inference.py` (swappable backend) → `crankycheck/prompt.py` (prompt + defensive JSON parsing). Parsing tests pass; the Ollama backend has been smoke-tested against a real image.
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. Crankycheck 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
- - `CRANKY_BACKEND=transformers uv run app.py` — run the same backend the HF Space uses (`google/gemma-4-E4B-it` via `transformers`).
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
- `CRANKY_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 crankiness 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,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 `CRANKY_PORT`. Already registered in the global port list.
48
 
49
  ## Architecture notes (load-bearing decisions)
50
 
51
- - **The VLM lives behind `crankycheck/inference.py`.** The rest of the app only calls `score_image(pil_image) -> CrankyVerdict` 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`).
 
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: Crankycheck
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 cranky does this social-media profile look to deal with?
11
  ---
12
 
13
- # 😤 Crankycheck
14
 
15
- Upload a social-media profile (or a screenshot of one) and Crankycheck gives a
16
- playful read on **how cranky that person looks to deal with** — a 0–100 score, a
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.** Crankycheck 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,11 +39,11 @@ so the local path is for UI work — real verdicts come from the Space.
39
 
40
  | Var | Default | Purpose |
41
  |-----|---------|---------|
42
- | `CRANKY_BACKEND` | `transformers` on a Space, else `ollama` | force a backend |
43
- | `CRANKY_HF_MODEL` | `google/gemma-4-E4B-it` | transformers model id |
44
- | `CRANKY_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
- | `CRANKY_PORT` | `7140` | local Gradio port |
47
 
48
  ## Tests
49
 
@@ -54,6 +54,6 @@ uv run pytest
54
  ## Project layout
55
 
56
  - `app.py` — Gradio UI.
57
- - `crankycheck/inference.py` — backend abstraction (Ollama ↔ transformers).
58
- - `crankycheck/prompt.py` — system prompt + defensive JSON parsing.
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
- """Crankycheck — Gradio app.
2
 
3
- Upload a social-media profile screenshot; get a playful "crankiness" 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: CRANKY_BACKEND=transformers uv run app.py
9
  """
10
 
11
  from __future__ import annotations
@@ -14,9 +14,9 @@ import os
14
 
15
  import gradio as gr
16
 
17
- from crankycheck import get_backend_name, score_image
18
 
19
- PORT = int(os.environ.get("CRANKY_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,7 +26,7 @@ PORT = int(os.environ.get("CRANKY_PORT", "7140"))
26
  PASTE_JS = """
27
  () => {
28
  const inject = (blob) => {
29
- const root = document.querySelector('#cranky_image');
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
- "🎈 *Crankycheck 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,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}** — crankiness **{verdict.score}/100**\n\n"
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="Crankycheck") as demo:
91
- gr.Markdown("# 😤 Crankycheck\n### How cranky does this profile look to deal with?")
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="cranky_image",
100
  )
101
- go = gr.Button("Check the crank ☕", variant="primary")
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 = "crankycheck"
3
  version = "0.1.0"
4
- description = "Estimate how cranky a social-media profile looks, from an image (Gemma 4 E4B)."
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, CRANKY_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
 
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
- """Crankycheck — estimate how cranky a social-media profile looks, from an image."""
2
 
3
- from .prompt import CrankyVerdict, build_messages, parse_verdict
4
  from .inference import score_image, get_backend_name
5
 
6
  __all__ = [
7
- "CrankyVerdict",
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 Crankycheck.
2
 
3
- Two interchangeable backends, selected by the CRANKY_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,7 +26,7 @@ from PIL import Image
26
  from .prompt import (
27
  SYSTEM_PROMPT,
28
  USER_INSTRUCTION,
29
- CrankyVerdict,
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
- "CRANKY_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,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
- "CRANKY_OLLAMA_MODEL", "huihui_ai/gemma-4-abliterated:e4b-q8_0"
46
  )
47
- HF_MODEL = os.environ.get("CRANKY_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,7 +162,7 @@ else:
162
  # --- Public API -------------------------------------------------------------
163
 
164
 
165
- def score_image(image: Image.Image) -> CrankyVerdict:
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 Crankycheck.
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 Crankycheck, 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
- "cranky to deal with" the person seems. This is a whimsical novelty read, NOT a
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 crankiness 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,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 crankiness JSON. Be playful, not mean."
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 CrankyVerdict:
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) -> CrankyVerdict:
124
- """Parse model output into a CrankyVerdict, 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,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 CrankyVerdict(
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 crankycheck.prompt import parse_verdict
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"