File size: 11,729 Bytes
a2a5f1d
 
 
 
8387116
 
a2a5f1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8387116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f623cda
8387116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2a5f1d
 
8387116
 
 
 
 
 
 
 
 
 
 
 
33e686f
 
8387116
 
 
 
 
 
 
 
 
a2a5f1d
8387116
 
a2a5f1d
8387116
a2a5f1d
 
8387116
 
 
 
 
33e686f
 
 
 
8387116
 
33e686f
8387116
 
 
 
 
 
 
a2a5f1d
 
 
 
 
 
 
8387116
a2a5f1d
 
 
 
 
 
 
 
 
 
8387116
a2a5f1d
 
 
8387116
 
a2a5f1d
8387116
a2a5f1d
 
 
 
 
 
 
8387116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2a5f1d
 
 
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
"""A free ZeroGPU demo of DecidaBERT-large: a state and typed questions in, a probability distribution per
question out, in one forward pass. This is a Gradio-native re-implementation for this Space; it does not import
or modify the `decida` package (see model.py). The full project — REST API, MCP, the real testbench of games —
lives at https://github.com/heldernoid/decida and runs the same model, plus Qwen3-0.6B and the Laya family,
locally on your own hardware."""
import html
import json
import time

import gradio as gr
import torch

from model import Primitive, Question, load, predict

try:
    import spaces  # only present when actually running on a Hugging Face Space
    GPU = spaces.GPU
except ImportError:  # running app.py locally, outside a Space
    def GPU(fn):
        return fn

# ZeroGPU: torch.cuda.is_available() is True even outside an @spaces.GPU call, through the emulation mode
# documented at https://huggingface.co/docs/hub/spaces-zerogpu, so this picks "cuda" correctly on the Space and
# falls back sanely (mps/cpu) when running app.py locally to test model.py's logic.
DEVICE = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
MODEL, LAYOUT = load(DEVICE)

PRESETS = {
    "Billing routing": {
        "state": "Customer: my invoice was charged twice and nobody answers the phone!",
        "questions": {
            "department": {"type": "choice", "instructions": "Which team should handle this?",
                            "criteria": {"billing": "Charges, invoices, refunds", "technical": "Bugs and outages", "sales": "Pricing, demos, new seats"}},
            "frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["Calm", "Frustrated", "Very angry"]},
            "urgent": {"type": "noul", "instructions": "Is this urgent and needs an answer today?"},
        },
    },
    "Tool-call gate": {
        "state": "Agent wants to run: rm -rf ./build && git push --force origin main",
        "questions": {
            "action": {"type": "choice", "instructions": "What should happen to this command?",
                       "criteria": {"allow": "Safe to run", "confirm": "Ask the user first", "block": "Do not run"}},
            "destructive": {"type": "noul", "instructions": "Can this command destroy work that cannot be recovered?"},
        },
    },
    "Churn risk": {
        "state": "Cancelled two seats last month and asked about export options this week.",
        "questions": {
            "risk": {"type": "score", "instructions": "How likely is this account to leave?", "criteria": ["Low", "Medium", "High"]},
            "call": {"type": "noul", "instructions": "Should a person reach out this week?"},
        },
    },
    "Content moderation": {
        "state": "Comment on a public forum: \"Nobody with half a brain would ever agree with this take, get a clue.\"",
        "questions": {
            "action": {"type": "choice", "instructions": "What should happen to this comment?",
                       "criteria": {"keep": "Within normal debate", "warn": "Rude, but not a policy breach", "remove": "Harassment or a personal attack"}},
            "toxic": {"type": "noul", "instructions": "Does this comment personally attack another user?"},
        },
    },
    "News topic": {
        "state": "The central bank raised interest rates by half a point to fight inflation, its third hike this year.",
        "questions": {
            "topic": {"type": "choice", "instructions": "What is this news about?",
                      "criteria": {"business": "Companies, markets, the economy", "sports": "Teams, athletes, results", "science": "Research, technology, space", "politics": "Government, elections, policy"}},
        },
    },
    "Product review": {
        "state": "Absolutely wonderful, the best purchase I have made this year. Fast shipping too.",
        "questions": {
            "stars": {"type": "score", "instructions": "How many stars did the reviewer give?", "criteria": ["1 star", "2 stars", "3 stars", "4 stars", "5 stars"]},
            "recommend": {"type": "noul", "instructions": "Would the reviewer recommend this to a friend?"},
        },
    },
    "Spam filter": {
        "state": "WINNER!! You have been selected for a $1000 gift card. Click http://bit.ly/x9 now to claim.",
        "questions": {
            "action": {"type": "choice", "instructions": "What should happen to this message?",
                       "criteria": {"inbox": "Legitimate, deliver normally", "spam": "Unsolicited mail or a scam"}},
        },
    },
    "PII detection": {
        "state": "My card number is 4111 1111 1111 1111 and it expires 09/28, please charge it today.",
        "questions": {
            "pii": {"type": "noul", "instructions": "Does the text contain payment card details?"},
            "urgent": {"type": "noul", "instructions": "Does the writer want this handled today?"},
        },
    },
    "Emotion": {
        "state": "Nobody told me the meeting was cancelled and I drove two hours for nothing.",
        "questions": {
            "emotion": {"type": "choice", "instructions": "Which emotion does the writer express?",
                        "criteria": {"joy": "Happy, excited, delighted", "sadness": "Sad, hurt, hopeless", "anger": "Angry, irritated, furious", "fear": "Afraid, anxious, worried"}},
        },
    },
    "Meeting request": {
        "state": "Could we push tomorrow's 9am sync to the afternoon? Something came up this morning.",
        "questions": {
            "urgency": {"type": "score", "instructions": "How urgent is this request?", "criteria": ["Not urgent", "Somewhat urgent", "Urgent"]},
            "reschedule": {"type": "noul", "instructions": "Is the writer asking to move a meeting?"},
        },
    },
}

CSS = """
:root { --bg:#f4f5f8; --card:#fff; --ink:#141a2e; --ink2:#4a5270; --ink3:#7b839e; --line:#e3e6ee; --accent:#4338ca; --track:#eceefb; }
.gradio-container { max-width: 1040px !important; margin: 0 auto !important; font-family: -apple-system, "Segoe UI", sans-serif !important; background: var(--bg) !important; }
#title h1 { font-size: 30px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 2px; }
#subtitle { color: var(--ink2); font-size: 15px; margin-bottom: 6px; }
.dc-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; }
.dc-q { padding: 12px 0; border-top: 1px solid var(--line); }
.dc-q:first-child { border-top: none; padding-top: 0; }
.dc-qhead { display:flex; align-items:center; gap:8px; font-weight:700; font-size:15px; color: var(--ink); margin-bottom: 2px; }
.dc-type { font: 700 10px ui-monospace, monospace; color: var(--ink3); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; text-transform: uppercase; }
.dc-instr { color: var(--ink2); font-size: 13px; margin-bottom: 8px; }
.dc-bar-row { display: grid; grid-template-columns: minmax(90px,160px) 1fr 48px; gap: 10px; align-items: center; margin: 5px 0; font-size: 13.5px; }
.dc-bar-track { display: block; height: 10px; border-radius: 6px; background: var(--track); overflow: hidden; }
.dc-bar-fill { display: block; height: 100%; border-radius: 6px; }
.dc-bar-label.top { font-weight: 700; color: var(--ink); }
.dc-bar-num { font-family: ui-monospace, monospace; text-align: right; color: var(--ink2); }
.dc-meta { color: var(--ink3); font-size: 12.5px; margin-top: 10px; }
.dc-meta b { color: var(--ink); }
"""


def _render(answers: dict, raw: dict) -> str:
    blocks = []
    for qid, a in answers.items():
        instr = html.escape(str(raw[qid].get("instructions", "")))
        rows = []
        if a["type"] == "noul":
            items = [("true", a["noul"]), ("false", 1 - a["noul"])]
        else:
            probs = a["probabilities"]
            items = list(probs.items())
        top_key = max(items, key=lambda kv: kv[1])[0]
        for k, v in items:
            label = f"{k}: {a['legend'][k]}" if a["type"] == "score" else str(k)
            top = "top" if k == top_key else ""
            # Colour scales with the value itself, not just a binary "is this the top answer": a near-zero bar
            # stays a faint sliver, a near-one bar is a solid, saturated fill.
            alpha = 0.15 + 0.80 * v
            fill_style = f"width:{v * 100:.1f}%; background:rgba(67,56,202,{alpha:.2f})"
            rows.append(
                f'<div class="dc-bar-row"><span class="dc-bar-label {top}">{html.escape(label)}</span>'
                f'<span class="dc-bar-track"><span class="dc-bar-fill" style="{fill_style}"></span></span>'
                f'<span class="dc-bar-num">{v:.2f}</span></div>'
            )
        blocks.append(
            f'<div class="dc-q"><div class="dc-qhead">{html.escape(qid)} <span class="dc-type">{a["type"]}</span></div>'
            f'<div class="dc-instr">{instr}</div>{"".join(rows)}</div>'
        )
    return f'<div class="dc-card">{"".join(blocks)}</div>'


def _questions_from_dict(raw: dict) -> dict[str, Question]:
    return {qid: Question(qid, Primitive(q["type"]), q.get("instructions", ""), q.get("criteria")) for qid, q in raw.items()}


def ask(state: str, raw_questions: dict) -> tuple[dict, float]:
    """One state, any number of typed questions, one forward pass."""
    t0 = time.perf_counter()
    answers = predict(MODEL, LAYOUT, state, _questions_from_dict(raw_questions))
    return answers, round((time.perf_counter() - t0) * 1000, 1)


@GPU
def answer(preset: str, state: str, questions_json: str):
    try:
        raw = json.loads(questions_json)
    except json.JSONDecodeError as exc:
        return f'<div class="dc-card">questions is not valid JSON: {html.escape(str(exc))}</div>', "", ""
    try:
        answers, ms = ask(state, raw)
    except (KeyError, ValueError) as exc:
        return f'<div class="dc-card">bad question: {html.escape(str(exc))}</div>', "", ""
    meta = f'<div class="dc-meta"><code>encoder</code> · specialist · calibrated · <b>{ms} ms</b> on {DEVICE} for {len(raw)} question(s)</div>'
    request = json.dumps({"model": "decidabert-large", "state": state, "questions": raw}, indent=2)
    return _render(answers, raw), meta, request


def _load_preset(name: str):
    p = PRESETS[name]
    return p["state"], json.dumps(p["questions"], indent=2)


with gr.Blocks(title="Decida", css=CSS) as demo:
    with gr.Column(elem_id="title"):
        gr.Markdown("# DecidaBERT-large")
        gr.Markdown(
            "One forward pass, no generated text: a **state** plus typed **questions** (`choice`, `score`, `noul`) in, "
            "a probability distribution per question out. Free ZeroGPU demo of [Decida](https://github.com/heldernoid/decida), "
            "a local runtime for these models.", elem_id="subtitle")
    with gr.Row():
        with gr.Column():
            preset = gr.Radio(list(PRESETS), value="Billing routing", label="Example")
            state = gr.Textbox(label="State", lines=3, value=PRESETS["Billing routing"]["state"])
            questions = gr.Code(label="Questions (JSON)", language="json", value=json.dumps(PRESETS["Billing routing"]["questions"], indent=2))
            run = gr.Button("Run", variant="primary")
        with gr.Column():
            out = gr.HTML()
            meta = gr.HTML()
            with gr.Accordion("Request sent", open=False):
                request_box = gr.Code(language="json")

    preset.change(_load_preset, preset, [state, questions])
    run.click(answer, [preset, state, questions], [out, meta, request_box])
    demo.load(answer, [preset, state, questions], [out, meta, request_box])

if __name__ == "__main__":
    demo.launch()