File size: 5,342 Bytes
aa00509
af9809a
aa00509
af9809a
 
 
 
aa00509
af9809a
 
 
 
 
 
 
 
aa00509
af9809a
aa00509
af9809a
 
 
 
 
 
9dee630
 
 
 
 
 
 
af9809a
 
9dee630
 
 
af9809a
aa00509
af9809a
 
 
 
 
 
9dee630
af9809a
 
 
 
 
 
 
 
 
9dee630
af9809a
 
 
 
9dee630
 
 
 
 
 
af9809a
 
 
 
 
 
 
 
ff18b7e
 
 
 
af9809a
 
 
 
 
 
 
 
 
aa00509
af9809a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e5e42f
 
 
 
 
 
 
 
aa00509
 
af9809a
 
 
 
 
 
 
aa00509
9dee630
af9809a
aa00509
af9809a
 
 
 
d40ce7b
 
 
8e5e42f
 
 
af9809a
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"""TemperCheck β€” Gradio app.

Upload a social-media profile screenshot; get a playful "temper" read from a
small Gemma 4 vision model. For the Build Small Hackathon (model <= 32B, Gradio
on Hugging Face Spaces).

Run locally (Ollama backend, default):   uv run app.py
Run the Spaces backend locally:          TEMPER_BACKEND=transformers uv run app.py
"""

from __future__ import annotations

import os

import gradio as gr

from tempercheck import get_backend_name, score_image

PORT = int(os.environ.get("TEMPER_PORT", "7140"))

# Bind real Ctrl-V: Gradio's "clipboard" source only adds a click-to-paste
# button, so we listen for the browser paste event ourselves and feed the
# pasted image into the component's hidden file input. If an image is already
# loaded the upload input is gone from the DOM, so we click the clear button
# first, then inject on the next tick.
#
# Guards against a perpetual-resize loop:
#   * __temperPasteBound β€” demo.load() can re-run on reconnect, so bind the
#     paste listener exactly once; stacked listeners inject the image several
#     times and thrash the layout.
#   * `busy` lock β€” ignore further paste events for a moment while one is being
#     processed, so a single Ctrl-V can't kick off overlapping injects.
PASTE_JS = """
() => {
  if (window.__temperPasteBound) return;
  window.__temperPasteBound = true;
  let busy = false;
  const inject = (blob) => {
    const root = document.querySelector('#temper_image');
    if (!root) return;
    let input = root.querySelector("input[type='file']");
    if (!input) {
      const clear = root.querySelector("button[aria-label='Clear'], button[title='Clear']");
      if (clear) {
        clear.click();
        return setTimeout(() => inject(blob), 80);
      }
      return;
    }
    const dt = new DataTransfer();
    dt.items.add(new File([blob], 'pasted.png', { type: blob.type || 'image/png' }));
    input.files = dt.files;
    input.dispatchEvent(new Event('change', { bubbles: true }));
  };
  window.addEventListener('paste', (e) => {
    if (busy) return;
    const items = (e.clipboardData || window.clipboardData)?.items || [];
    for (const it of items) {
      if (it.type && it.type.startsWith('image/')) {
        const blob = it.getAsFile();
        if (blob) {
          e.preventDefault();
          busy = true;
          inject(blob);
          setTimeout(() => { busy = false; }, 600);
        }
        return;
      }
    }
  });
}
"""

DISCLAIMER = (
    "TemperCheck is a fast LLM-based back-of-the-envelope calculation to see if a "
    "given social media profile is run by someone with a bad temper. It performs a "
    "quick visual analysis of a screenshot of a profile, and makes a hypothetical "
    "judgment accordingly. *It is NOT a real personality and says nothing factual "
    "about any actual person.*"
)


def _render(verdict) -> tuple[str, str]:
    bar = "β–ˆ" * (verdict.score // 5) + "β–‘" * (20 - verdict.score // 5)
    signals = "  ".join(f"`{s}`" for s in verdict.signals) if verdict.signals else "β€”"
    md = (
        f"## {verdict.band}\n\n"
        f"**{verdict.verdict}** β€” temper **{verdict.score}/100**\n\n"
        f"`{bar}`\n\n"
        f"{verdict.rationale}\n\n"
        f"**Signals:** {signals}"
    )
    return md, verdict.raw


def analyze(image):
    if image is None:
        return "Upload a profile screenshot to get a read.", ""
    try:
        return _render(score_image(image))
    except Exception as exc:  # surface backend/connection errors to the user
        return (
            f"⚠️ Could not score this image.\n\n```\n{type(exc).__name__}: {exc}\n```",
            "",
        )


def _disable_button():
    return gr.update(interactive=False)


def _enable_button():
    return gr.update(interactive=True)


with gr.Blocks(title="TemperCheck") as demo:
    gr.Markdown("# 😀 TemperCheck\n### How short a temper does this profile look to have?")
    gr.Markdown(DISCLAIMER)
    with gr.Row():
        with gr.Column():
            image_in = gr.Image(
                type="pil",
                label="Profile image / screenshot",
                sources=["upload", "clipboard"],
                elem_id="temper_image",
                height=400,  # fixed box stops the paste-time resize feedback loop
            )
            go = gr.Button("Check the temper β˜•", variant="primary")
        with gr.Column():
            result = gr.Markdown(label="Verdict")
            with gr.Accordion("Raw model output (agent trace)", open=False):
                raw = gr.Code(label="raw")
    # Only the button runs a check β€” pasting/uploading just loads the image.
    # Disable the button while the check runs, then re-enable it; analyze() never
    # raises, so the final .then always restores the button.
    go.click(_disable_button, None, go).then(
        analyze, inputs=image_in, outputs=[result, raw]
    ).then(_enable_button, None, go)
    gr.Markdown(f"<sub>Backend: `{get_backend_name()}`</sub>")
    demo.load(None, None, None, js=PASTE_JS)


if __name__ == "__main__":
    if os.environ.get("SPACE_ID"):
        # On Hugging Face Spaces, let the platform manage host/port.
        demo.launch(theme=gr.themes.Soft())
    else:
        demo.launch(
            server_name="0.0.0.0", server_port=PORT, theme=gr.themes.Soft()
        )