JiunYi commited on
Commit
dc580a2
·
verified ·
1 Parent(s): 2f30821

Phase 1.0b: redesigned demo (Quorum main c013b4e)

Browse files
Files changed (3) hide show
  1. README.md +18 -14
  2. index.html +505 -171
  3. samples.json +0 -0
README.md CHANGED
@@ -1,26 +1,30 @@
1
  ---
2
  title: Quorum
3
  emoji: ⚖️
4
- colorFrom: indigo
5
  colorTo: gray
6
  sdk: static
7
  pinned: false
8
  license: mit
9
  ---
10
 
11
- # Quorum — live demo (static frontend)
12
 
13
- A jury of tiny open models decides an intent and tells you how sure it is. This Space is a **static** browser
14
- frontend (`index.html`): you paste the URL of a running Quorum API and it calls it live.
 
 
15
 
16
- - Run the API yourself: see [`serve/`](https://github.com/DataAgent-Lab/Quorum/tree/main/serve) in the repo
17
- (`pip install -e ".[serve]"` → `uvicorn serve.app:app`), then expose it over HTTPS and paste the URL here.
18
- - Code + study: https://github.com/DataAgent-Lab/Quorum
19
- - Collection: https://huggingface.co/collections/DataAgent/quorum-6abb3255e0fab20d66add39b
 
 
 
 
 
20
 
21
- **Why static?** Hosting a compute (Gradio) Space needs a paid HF plan; a static Space is free and calls your
22
- own self-hosted API — so the demo stays free and you keep the inference on your machine.
23
-
24
- **Honest note.** This is the zero-shot tier — cheap and calibrated, but *not* open zero-shot state-of-the-art.
25
- The 24-shot pipeline that edges the closed API Jev (0.932 vs 0.924 on Banking77) uses a gated LoRA adapter,
26
- `DataAgent/Quorum-Reader-Qwen3-4B-Adapter`.
 
1
  ---
2
  title: Quorum
3
  emoji: ⚖️
4
+ colorFrom: red
5
  colorTo: gray
6
  sdk: static
7
  pinned: false
8
  license: mit
9
  ---
10
 
11
+ # Quorum — ask the jury
12
 
13
+ A jury of three tiny open models reads a message, each casts a vote, and Quorum returns the verdict with a confidence
14
+ you can act on. Every example on the page is a **real test sentence** from one of seven public intent benchmarks
15
+ (Banking77, CLINC150, HWU64, MASSIVE, MTOP, SNIPS, Bitext), so you can see when the jury gets it right — and when it
16
+ doesn't. You can also type your own sentence and labels.
17
 
18
+ - The page is static (`index.html` + `samples.json`) and calls a self-hosted Quorum API. Benchmark examples are
19
+ answered instantly from the API's cache; your own sentences are computed live (the first one after a quiet spell
20
+ takes a minute or two while the models wake up). Sentences you type are kept on the demo server so repeats answer
21
+ instantly.
22
+ - Run your own API: see [`serve/`](https://github.com/DataAgent-Lab/Quorum/tree/main/serve) and set `DEFAULT_API` in
23
+ your copy of `index.html` (when the page is served from localhost, `?api=https://your-api.example` also works).
24
+ - `samples.json` is generated from the per-item benchmark predictions: `python -m serve.export_demo_samples`.
25
+ - Code + study: https://github.com/DataAgent-Lab/Quorum ·
26
+ Collection: https://huggingface.co/collections/DataAgent/quorum-6abb3255e0fab20d66add39b
27
 
28
+ **Honest note.** This is the zero-shot tier — cheap and calibrated, but *not* open zero-shot state-of-the-art: the
29
+ closed API Jev (an independent reproduction, ~0.801 on Banking77) still leads zero-shot. The 24-shot pipeline that
30
+ edges it (0.932 vs 0.924 on Banking77) uses a gated LoRA adapter, `DataAgent/Quorum-Reader-Qwen3-4B-Adapter`.
 
 
 
index.html CHANGED
@@ -3,197 +3,531 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Quorum — calibrated intent decisions from tiny open models</title>
7
- <meta name="description" content="Pick a decision and candidate labels; Quorum returns the choice, a confidence you can gate on, and how each model voted. An ensemble of three sub-1B open models — no training, runs on a laptop." />
8
  <meta property="og:type" content="website" />
9
- <meta property="og:title" content="Quorum — calibrated intent decisions from tiny open models" />
10
- <meta property="og:description" content="A jury of three sub-1B open models decides an intent and tells you how sure it is — no training, no paid API. Try it live." />
11
  <meta property="og:url" content="https://huggingface.co/spaces/DataAgent/Quorum-Demo" />
12
  <meta name="twitter:card" content="summary" />
13
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234f46e5'/%3E%3Ctext x='16' y='23' font-size='20' text-anchor='middle' fill='white'%3E%E2%9A%96%3C/text%3E%3C/svg%3E" />
 
 
 
14
  <style>
15
- :root { --ink:#1a1a2e; --muted:#6b7280; --line:#e5e7eb; --accent:#4f46e5; --accent-soft:#eef0ff;
16
- --bg:#ffffff; --card:#ffffff; --ok:#047857; --ok-bg:#ecfdf5; --warn:#b45309; --warn-bg:#fffbeb; }
 
 
 
 
 
 
 
 
17
  @media (prefers-color-scheme: dark) {
18
- :root { --ink:#e8e8f0; --muted:#9aa0ac; --line:#2b2f3a; --accent:#8b8cf7; --accent-soft:#20223a;
19
- --bg:#14151c; --card:#1b1d26; --ok:#34d399; --ok-bg:#0d2b22; --warn:#fbbf24; --warn-bg:#2a2113; }
 
 
 
 
20
  }
21
  * { box-sizing: border-box; }
22
- body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
- color: var(--ink); background: var(--bg); margin: 0; line-height: 1.55; }
24
- .wrap { max-width: 760px; margin: 0 auto; padding: 28px 18px 40px; }
25
- h1 { font-size: 1.55rem; margin: 0 0 6px; letter-spacing: -.01em; }
26
- .sub { color: var(--muted); margin: 0 0 14px; }
27
- .links a { color: var(--accent); text-decoration: none; margin-right: 14px; font-size: .9rem; }
28
- .links a:hover { text-decoration: underline; }
29
- .card { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-top: 16px; background: var(--card); }
30
- label { display:block; font-weight:600; font-size:.85rem; margin: 12px 0 4px; }
31
- input[type=text], textarea { width:100%; padding:9px 11px; border:1px solid var(--line);
32
- border-radius:8px; font: inherit; background: var(--bg); color: var(--ink); }
33
- textarea { resize: vertical; }
34
- .presets { display:flex; flex-wrap:wrap; gap:8px; margin-top:4px; }
35
- .chip { border:1px solid var(--line); background:var(--bg); color:var(--ink); border-radius:999px;
36
- padding:6px 13px; font-size:.85rem; cursor:pointer; }
37
- .chip:hover { border-color: var(--accent); color: var(--accent); }
38
- .go { margin-top: 16px; background: var(--accent); color:#fff; border:0; border-radius:8px;
39
- padding: 11px 20px; font-weight:600; font-size:.95rem; cursor:pointer; }
40
- .go:disabled { opacity:.55; cursor:default; }
41
- .note { font-size:.8rem; color:var(--muted); margin-top:8px; }
42
- .spin { display:inline-block; width:14px; height:14px; border:2px solid rgba(255,255,255,.5);
43
- border-top-color:#fff; border-radius:50%; animation:spin .7s linear infinite; vertical-align:-2px; margin-right:7px; }
44
- @keyframes spin { to { transform: rotate(360deg); } }
45
- .status { font-size:.85rem; color:var(--muted); margin-top:10px; }
46
- .badge { display:inline-block; background:var(--accent-soft); color:var(--accent); font-weight:700;
47
- font-size:1.15rem; padding:4px 12px; border-radius:8px; }
48
- .meta { font-size:.8rem; color:var(--muted); margin: 6px 0 2px; }
49
- .bar { height: 20px; background: var(--accent); border-radius: 4px; }
50
- .row { display:flex; align-items:center; gap:8px; margin: 5px 0; font-size:.9rem; }
51
- .rowlab { width: 40%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
52
- .barwrap { flex:1; background:var(--accent-soft); border-radius:4px; }
53
- .pct { width: 52px; text-align:right; color: var(--muted); font-variant-numeric: tabular-nums; }
54
- .members { font-size:.85rem; margin-top:14px; }
55
- .members b { color: var(--accent); }
56
- .gate { margin-top:16px; border-top:1px dashed var(--line); padding-top:14px; }
57
- .gate input[type=range] { width:100%; accent-color: var(--accent); }
58
- .decision { margin-top:10px; padding:10px 12px; border-radius:8px; font-weight:600; font-size:.95rem; }
59
- .decision.act { background:var(--ok-bg); color:var(--ok); }
60
- .decision.esc { background:var(--warn-bg); color:var(--warn); }
61
- .err { color:#dc2626; font-size:.9rem; margin-top:10px; white-space:pre-wrap; }
62
- details { margin-top:16px; }
63
- summary { cursor:pointer; color:var(--muted); font-size:.85rem; }
64
- footer { margin-top:28px; padding-top:16px; border-top:1px solid var(--line); font-size:.85rem; color:var(--muted); }
65
- footer a { color: var(--accent); text-decoration:none; }
66
- .cta { display:inline-block; margin-top:8px; font-weight:600; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  </style>
68
  </head>
69
  <body>
70
  <div class="wrap">
71
- <h1>⚖️ Quorum</h1>
72
- <p class="sub">Turn a plain message into a decision you can act on — the chosen label, a confidence you can set
73
- a threshold on, and a look at how the models voted. No training, no paid API; three sub-1B open models
74
- running on ordinary hardware.</p>
75
- <div class="links">
76
- <a href="https://github.com/DataAgent-Lab/Quorum" target="_blank" rel="noopener">GitHub</a>
77
- <a href="https://huggingface.co/collections/DataAgent/quorum-6abb3255e0fab20d66add39b" target="_blank" rel="noopener">Models</a>
78
- <a href="https://huggingface.co/DataAgent/Quorum-Reader-Qwen3-4B-Adapter" target="_blank" rel="noopener">24-shot adapter</a>
79
- </div>
80
-
81
- <div class="card">
82
- <label>Try an example</label>
83
- <div class="presets" id="presets"></div>
84
-
85
- <label for="msg">Message</label>
86
- <textarea id="msg" rows="2">when will my new card arrive?</textarea>
87
- <label for="labels">Candidate labels (one per line)</label>
88
- <textarea id="labels" rows="6">card arrival
89
- card delivery estimate
90
- lost or stolen card
91
- change pin
92
- top up by card
93
- exchange rate</textarea>
94
- <button class="go" id="go">Decide</button>
95
- <div class="status" id="status"></div>
96
- <div class="err" id="err"></div>
97
- </div>
98
-
99
- <div class="card" id="out" style="display:none">
100
- <div class="meta">Top choice</div>
101
- <div class="badge" id="toplab"></div>
102
- <span class="meta" id="topconf"></span>
103
- <div id="bars" style="margin-top:12px"></div>
104
- <div class="members" id="members"></div>
105
-
106
- <div class="gate">
107
- <label for="tau" style="margin:0">Confidence gate — auto-act when confidence ≥ <span id="tauval">70</span>%</label>
108
- <input type="range" id="tau" min="0" max="100" value="70" />
109
- <div class="decision" id="decision"></div>
110
- <div class="note">The gate is what turns a prediction into an action: auto-act above the line, escalate to a
111
- human below it. This live demo is the zero-shot tier (uncalibrated); the self-hosted service adds
112
- temperature calibration and a precision-<em>guaranteed</em> (conformal) gate — see the repo.</div>
113
- </div>
114
- </div>
 
 
 
115
 
116
  <footer>
117
- Built by <b>DataAgent</b>. <a href="https://github.com/DataAgent-Lab/Quorum" target="_blank" rel="noopener">Open source</a>,
118
- with a small reproduction study comparing against a closed commercial API (24-shot: 0.932 vs 0.924 on Banking77).<br>
119
- <a class="cta" href="https://github.com/DataAgent-Lab/Quorum/issues" target="_blank" rel="noopener">Want this on your own data / labels? Start a conversation →</a>
120
  </footer>
121
  </div>
122
 
 
123
  <script>
124
- const $ = id => document.getElementById(id);
125
- const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
126
- const API_BASE = "https://quorum-api.idataagent.com";
127
-
128
- const EXAMPLES = {
129
- "Banking": { msg: "when will my new card arrive?",
130
- labels: ["card arrival","card delivery estimate","lost or stolen card","change pin","top up by card","exchange rate"] },
131
- "Support triage": { msg: "I was charged twice for the same order and want my money back",
132
- labels: ["billing / double charge","refund request","order status","cancel subscription","technical bug","general question"] },
133
- "Product feedback": { msg: "the new dashboard is gorgeous but it loads really slowly",
134
- labels: ["praise","performance complaint","bug report","feature request","pricing concern","churn risk"] },
135
- };
136
- const presets = $("presets");
137
- Object.keys(EXAMPLES).forEach(name => {
138
- const b = document.createElement("button");
139
- b.className = "chip"; b.textContent = name;
140
- b.onclick = () => { $("msg").value = EXAMPLES[name].msg; $("labels").value = EXAMPLES[name].labels.join("\n"); };
141
- presets.appendChild(b);
142
- });
143
-
144
- let lastConf = null;
145
- function renderDecision() {
146
- if (lastConf === null) return;
147
- const tau = +$("tau").value; $("tauval").textContent = tau;
148
- const el = $("decision"); const c = (lastConf * 100);
149
- if (c >= tau) { el.className = "decision act"; el.textContent = `✅ Auto-act — confidence ${c.toFixed(1)}% ≥ ${tau}%`; }
150
- else { el.className = "decision esc"; el.textContent = `⚠️ Escalate to a human — confidence ${c.toFixed(1)}% < ${tau}%`; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  }
152
- $("tau").addEventListener("input", renderDecision);
153
-
154
- async function decide() {
155
- $("err").textContent = ""; $("status").textContent = ""; $("out").style.display = "none";
156
- const base = API_BASE;
157
- const message = $("msg").value.trim();
158
- const labels = $("labels").value.split("\n").map(s => s.trim()).filter((s, i, a) => s && a.indexOf(s) === i);
159
- if (!message) { $("err").textContent = "Enter a message."; return; }
160
- if (labels.length < 2) { $("err").textContent = "Enter at least 2 labels (one per line)."; return; }
161
-
162
- $("go").disabled = true; $("go").innerHTML = '<span class="spin"></span>Deciding…';
163
- const warmTimer = setTimeout(() => { $("status").textContent = "Running on a small shared CPU server — a prediction takes a few seconds (longer under load)…"; }, 1200);
164
- const ctrl = new AbortController();
165
- const timeout = setTimeout(() => ctrl.abort(), 40000);
166
- const t0 = performance.now();
167
- try {
168
- const r = await fetch(base + "/predict", {
169
- method: "POST", headers: { "content-type": "application/json" },
170
- body: JSON.stringify({ message, labels }), signal: ctrl.signal,
171
- });
172
- if (!r.ok) throw new Error("The server returned HTTP " + r.status + ".");
173
- const d = await r.json();
174
- const secs = ((performance.now() - t0) / 1000).toFixed(1);
175
- $("toplab").textContent = d.label;
176
- $("topconf").textContent = ` confidence ${(d.confidence * 100).toFixed(1)}% · decided in ${secs}s`;
177
- const entries = Object.entries(d.distribution);
178
- $("bars").innerHTML = entries.map(([k, v]) =>
179
- `<div class="row"><div class="rowlab" title="${esc(k)}">${esc(k)}</div>` +
180
- `<div class="barwrap"><div class="bar" style="width:${Math.max(2, v * 100).toFixed(1)}%"></div></div>` +
181
- `<div class="pct">${(v * 100).toFixed(1)}%</div></div>`).join("");
182
- $("members").innerHTML = "<b>How the jury voted</b><br>" +
183
- (d.members || []).map(m => `${esc(m.model)} → <b>${esc(m.pick)}</b>`).join("<br>");
184
- lastConf = d.confidence; renderDecision();
185
- $("out").style.display = "block";
186
- } catch (e) {
187
- $("err").textContent = (e.name === "AbortError")
188
- ? "The demo server didn't respond in time — it may be busy. Please try again in a few seconds."
189
- : "Couldn't reach the demo server. Please try again shortly.";
190
- } finally {
191
- clearTimeout(warmTimer); clearTimeout(timeout);
192
- $("status").textContent = "";
193
- $("go").disabled = false; $("go").textContent = "Decide";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  }
196
- $("go").addEventListener("click", decide);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  </script>
198
  </body>
199
  </html>
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Quorum — ask the jury</title>
7
+ <meta name="description" content="Three tiny open models read a message, each casts a vote, and Quorum returns a verdict with a confidence you can act on. Try it on real benchmark sentences." />
8
  <meta property="og:type" content="website" />
9
+ <meta property="og:title" content="Quorum — ask the jury" />
10
+ <meta property="og:description" content="A jury of three sub-1B open models decides an intent and tells you how sure it is — no training, no paid API. Try it on real benchmark sentences." />
11
  <meta property="og:url" content="https://huggingface.co/spaces/DataAgent/Quorum-Demo" />
12
  <meta name="twitter:card" content="summary" />
13
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23b8391f'/%3E%3Ctext x='16' y='23' font-size='20' text-anchor='middle' fill='white'%3E%E2%9A%96%3C/text%3E%3C/svg%3E" />
14
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
16
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Hanken+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
17
  <style>
18
+ :root {
19
+ --paper:#f4efe5; --paper-2:#ece4d5; --card:#fbf8f1; --ink:#1c1813; --ink-2:#4b443a; --muted:#7c7367;
20
+ --line:#ddd3c3; --line-2:#cbbfab; --seal:#b8391f; --seal-2:#d9573a; --seal-soft:#f5ddd3;
21
+ --ok:#2c6a3c; --ok-soft:#e1efe1; --no:#a0341f; --no-soft:#f6e1da;
22
+ --shadow: 0 1px 0 rgba(28,24,19,.04), 0 10px 30px -18px rgba(28,24,19,.35);
23
+ --serif: "Instrument Serif", "Iowan Old Style", Georgia, serif;
24
+ --sans: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;
25
+ --mono: "IBM Plex Mono", ui-monospace, Menlo, monospace;
26
+ --ease: cubic-bezier(.2,.8,.2,1);
27
+ }
28
  @media (prefers-color-scheme: dark) {
29
+ :root:not([data-theme="light"]) {
30
+ --paper:#13100c; --paper-2:#1a1611; --card:#1c1813; --ink:#f0e8da; --ink-2:#cfc4b3; --muted:#9b9081;
31
+ --line:#2e2821; --line-2:#3c342b; --seal:#ee6a49; --seal-2:#ff8a6a; --seal-soft:#3a1d15;
32
+ --ok:#86c897; --ok-soft:#16271b; --no:#f2907a; --no-soft:#35190f;
33
+ --shadow: 0 1px 0 rgba(0,0,0,.4), 0 16px 40px -20px rgba(0,0,0,.8);
34
+ }
35
  }
36
  * { box-sizing: border-box; }
37
+ html { -webkit-text-size-adjust: 100%; }
38
+ body { margin:0; font-family:var(--sans); color:var(--ink); background:var(--paper); line-height:1.55;
39
+ font-size:15.5px; min-height:100vh; }
40
+ body::before { /* paper grain */
41
+ content:""; position:fixed; inset:0; pointer-events:none; z-index:0; opacity:.5;
42
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .07 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
43
+ }
44
+ .wrap { position:relative; z-index:1; max-width:1120px; margin:0 auto; padding:22px 20px 56px; }
45
+ a { color:inherit; }
46
+
47
+ /* masthead */
48
+ .mast { display:flex; justify-content:space-between; align-items:baseline; gap:16px; flex-wrap:wrap;
49
+ border-bottom:1px solid var(--line); padding-bottom:14px; }
50
+ .brand { font:400 1.65rem/1 var(--serif); letter-spacing:.01em; }
51
+ .brand small { font:500 .78rem var(--mono); color:var(--muted); margin-left:10px; letter-spacing:.04em; }
52
+ .mast nav { display:flex; gap:18px; font-size:.88rem; }
53
+ .mast nav a { text-decoration:none; color:var(--ink-2); border-bottom:1px solid transparent; }
54
+ .mast nav a:hover { border-color:var(--ink-2); }
55
+
56
+ /* hero */
57
+ .hero { display:grid; grid-template-columns: 1.15fr .85fr; gap:28px; align-items:end; margin:34px 0 26px; }
58
+ .hero h1 { font:400 clamp(2.6rem, 6vw, 4.6rem)/.95 var(--serif); margin:0; letter-spacing:-.01em; }
59
+ .hero h1 em { color:var(--seal); }
60
+ .hero p { margin:0; color:var(--ink-2); font-size:1rem; max-width:44ch; }
61
+
62
+ /* court: case | verdict */
63
+ .court { display:grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap:22px; align-items:start; }
64
+ .card { background:var(--card); border:1px solid var(--line); border-radius:16px; box-shadow:var(--shadow); }
65
+ .case { padding:22px 22px 20px; }
66
+ .step { display:flex; align-items:baseline; gap:10px; font:500 .72rem/1 var(--mono); letter-spacing:.12em;
67
+ text-transform:uppercase; color:var(--muted); margin:2px 0 12px; }
68
+ .step b { color:var(--seal); font-weight:500; }
69
+ .step + * + .step, .block + .step { margin-top:22px; }
70
+
71
+ /* domain chips */
72
+ .domains { display:flex; flex-wrap:wrap; gap:8px; }
73
+ .chip { text-align:left; border:1px solid var(--line); background:var(--paper); color:var(--ink);
74
+ border-radius:12px; padding:9px 11px 8px; cursor:pointer; font:inherit;
75
+ transition: border-color .2s, background .2s, transform .2s var(--ease); }
76
+ .chip:hover { border-color:var(--line-2); transform:translateY(-1px); }
77
+ .chip .t { display:block; font-weight:600; font-size:.88rem; line-height:1.2; white-space:nowrap; }
78
+ .chip .s { display:block; font:400 .7rem var(--mono); color:var(--muted); margin-top:3px; white-space:nowrap; }
79
+ .chip[aria-pressed="true"] { background:var(--ink); color:var(--paper); border-color:var(--ink); }
80
+ .chip[aria-pressed="true"] .s { color:color-mix(in srgb, var(--paper) 70%, transparent); }
81
+ .chip.own .t::before { content:"✎ "; }
82
+ .chip:focus-visible, .btn:focus-visible, .linkbtn:focus-visible, textarea:focus-visible, input:focus-visible {
83
+ outline:2px solid var(--seal); outline-offset:2px; }
84
+
85
+ /* message */
86
+ .fieldhead { display:flex; justify-content:space-between; align-items:center; gap:10px; margin-bottom:7px; }
87
+ .tag { font:500 .7rem/1 var(--mono); letter-spacing:.04em; color:var(--ok); background:var(--ok-soft);
88
+ border-radius:999px; padding:5px 9px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
89
+ .tag.edited { color:var(--muted); background:var(--paper-2); }
90
+ .linkbtn { background:none; border:0; padding:4px 2px; color:var(--ink-2); font:500 .82rem var(--sans);
91
+ cursor:pointer; text-decoration:underline; text-decoration-color:var(--line-2); text-underline-offset:3px; }
92
+ .linkbtn:hover { color:var(--seal); text-decoration-color:var(--seal); }
93
+ textarea { width:100%; border:1px solid var(--line); border-radius:12px; background:var(--paper); color:var(--ink);
94
+ font: 400 1.12rem/1.45 var(--serif); padding:12px 14px; resize:vertical; min-height:74px; }
95
+ textarea#own-labels { font:400 .92rem/1.5 var(--sans); min-height:140px; }
96
+
97
+ /* labels summary */
98
+ .labels-sum { display:flex; justify-content:space-between; align-items:center; gap:12px; border:1px solid var(--line);
99
+ border-radius:12px; background:var(--paper); padding:11px 14px; }
100
+ .labels-sum .n { font:400 1.35rem/1 var(--serif); }
101
+ .labels-sum .what { font-size:.88rem; color:var(--ink-2); }
102
+ .labels-list { margin-top:8px; max-height:220px; overflow:auto; border:1px solid var(--line); border-radius:12px;
103
+ padding:4px 0; background:var(--paper); }
104
+ .labels-list li { list-style:none; padding:6px 14px; border-bottom:1px dashed var(--line); }
105
+ .labels-list li:last-child { border-bottom:0; }
106
+ .labels-list .nm { font-weight:600; font-size:.88rem; }
107
+ .labels-list .ds { display:block; font-size:.76rem; color:var(--muted); }
108
+ .hint { font-size:.8rem; color:var(--muted); margin-top:6px; }
109
+ .hint.warn { color:var(--no); }
110
+
111
+ .actions { display:flex; align-items:center; gap:14px; margin-top:22px; flex-wrap:wrap; }
112
+ .btn { position:relative; border:0; border-radius:12px; padding:13px 22px 12px; cursor:pointer;
113
+ background:var(--ink); color:var(--paper); font:600 .98rem var(--sans); letter-spacing:.01em;
114
+ transition: transform .2s var(--ease), background .2s; }
115
+ .btn:hover { transform:translateY(-1px); background:var(--seal); }
116
+ .btn:disabled { opacity:.45; cursor:not-allowed; transform:none; background:var(--ink); }
117
+ .btn .arrow { display:inline-block; transition:transform .25s var(--ease); margin-left:6px; }
118
+ .btn:hover .arrow { transform:translateX(3px); }
119
+ .privacy { font-size:.74rem; color:var(--muted); max-width:34ch; }
120
+
121
+ /* verdict panel */
122
+ .verdict { padding:22px; min-height:520px; position:sticky; top:18px; overflow:hidden; }
123
+ .empty { height:100%; min-height:470px; display:grid; place-items:center; text-align:center; color:var(--muted); }
124
+ .empty .glyph { font:400 4.2rem/1 var(--serif); color:var(--line-2); }
125
+ .empty p { max-width:30ch; margin:10px auto 0; font-size:.92rem; }
126
+
127
+ .jurors { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:8px; perspective:900px; }
128
+ .juror { border:1px solid var(--line); border-radius:12px; padding:10px 10px 9px; background:var(--paper);
129
+ min-height:86px; transform-origin:50% 0; }
130
+ .juror .who { font:500 .66rem/1.2 var(--mono); letter-spacing:.05em; color:var(--muted); text-transform:uppercase; }
131
+ .juror .role { font-size:.72rem; color:var(--muted); }
132
+ .juror .vote { margin-top:7px; font-weight:600; font-size:.86rem; line-height:1.25; }
133
+ .juror .agree { font:500 .66rem var(--mono); margin-top:4px; letter-spacing:.04em; }
134
+ .juror .agree.y { color:var(--ok); } .juror .agree.n { color:var(--no); }
135
+ .juror.thinking .vote { color:var(--muted); }
136
+ .dots span { display:inline-block; width:5px; height:5px; border-radius:50%; background:var(--muted); margin-right:3px;
137
+ animation: blink 1.2s infinite both; }
138
+ .dots span:nth-child(2) { animation-delay:.2s } .dots span:nth-child(3) { animation-delay:.4s }
139
+ @keyframes blink { 0%,80%,100% { opacity:.2 } 40% { opacity:1 } }
140
+
141
+ .ruling { display:grid; grid-template-columns: minmax(0,1fr) auto; gap:16px; align-items:center;
142
+ margin:22px 0 6px; padding:18px 0 16px; border-top:1px solid var(--line); border-bottom:1px solid var(--line); }
143
+ .eyebrow { font:500 .7rem/1 var(--mono); letter-spacing:.14em; text-transform:uppercase; color:var(--seal); }
144
+ .ruling .name { font:400 clamp(2rem, 4.2vw, 2.9rem)/1 var(--serif); margin:8px 0 6px; overflow-wrap:anywhere; }
145
+ .ruling .desc { font-size:.86rem; color:var(--ink-2); }
146
+ .seal { width:136px; height:136px; position:relative; flex:none; }
147
+ .seal svg { position:absolute; inset:0; }
148
+ .seal .num { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:var(--seal); }
149
+ .seal .num b { display:block; font:500 1.18rem/1 var(--mono); letter-spacing:-.03em; }
150
+ .seal .num i { display:block; font:500 .5rem/1 var(--mono); letter-spacing:.18em; text-transform:uppercase;
151
+ font-style:normal; margin-top:5px; opacity:.85; }
152
+
153
+ .truth { display:flex; gap:10px; align-items:flex-start; border-radius:12px; padding:11px 13px; margin-top:14px;
154
+ font-size:.9rem; }
155
+ .truth .ic { font:600 1rem/1.3 var(--mono); }
156
+ .truth.ok { background:var(--ok-soft); color:var(--ok); }
157
+ .truth.no { background:var(--no-soft); color:var(--no); }
158
+ .truth.na { background:var(--paper-2); color:var(--ink-2); }
159
+ .truth small { display:block; color:var(--ink-2); opacity:.85; margin-top:2px; font-size:.78rem; }
160
+
161
+ .bars { margin-top:16px; }
162
+ .bar-row { display:grid; grid-template-columns: minmax(0, 1fr) 54px; gap:4px 10px; align-items:center; padding:6px 0; }
163
+ .bar-row .lab { font-size:.86rem; font-weight:600; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
164
+ .bar-row .lab span { font-weight:400; color:var(--muted); font-size:.74rem; margin-left:6px; }
165
+ .bar-row .pct { font:500 .8rem var(--mono); text-align:right; color:var(--ink-2); grid-row: span 2; }
166
+ .track { height:6px; background:var(--paper-2); border-radius:99px; overflow:hidden; }
167
+ .fill { height:100%; width:0; background:var(--line-2); border-radius:99px; transition: width .9s var(--ease); }
168
+ .bar-row.top .fill { background:var(--seal); }
169
+ .bar-row.top .lab { color:var(--seal); }
170
+ .more { margin-top:4px; }
171
+
172
+ .gate { margin-top:18px; padding-top:14px; border-top:1px dashed var(--line); }
173
+ .gate-head { display:flex; justify-content:space-between; font-size:.84rem; color:var(--ink-2); }
174
+ .gate-head b { font-family:var(--mono); font-weight:500; }
175
+ input[type=range] { width:100%; accent-color:var(--seal); margin:10px 0 6px; }
176
+ .decision { border-radius:10px; padding:10px 12px; font-weight:600; font-size:.9rem; }
177
+ .decision.act { background:var(--ok-soft); color:var(--ok); }
178
+ .decision.esc { background:var(--no-soft); color:var(--no); }
179
+ .footnote { margin-top:14px; font:400 .72rem/1.5 var(--mono); color:var(--muted); display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
180
+ .err { margin-top:14px; color:var(--no); font-size:.95rem; }
181
+ .err .btn { margin-top:14px; }
182
+ .waking { margin-top:18px; border:1px dashed var(--line-2); border-radius:12px; padding:14px 16px; }
183
+ .waking b { display:block; font:400 1.35rem/1.15 var(--serif); }
184
+ .waking p { margin:6px 0 0; font-size:.86rem; color:var(--ink-2); }
185
+ .waking .clock { font:500 .8rem var(--mono); color:var(--seal); margin-top:8px; }
186
+
187
+ /* reveal choreography */
188
+ @media (prefers-reduced-motion: no-preference) {
189
+ .reveal .juror { animation: flip .55s var(--ease) both; }
190
+ .reveal .juror:nth-child(2) { animation-delay:.12s } .reveal .juror:nth-child(3) { animation-delay:.24s }
191
+ .reveal .ruling .eyebrow, .reveal .ruling .name, .reveal .ruling .desc { animation: rise .6s var(--ease) both; }
192
+ .reveal .ruling .eyebrow { animation-delay:.42s } .reveal .ruling .name { animation-delay:.5s }
193
+ .reveal .ruling .desc { animation-delay:.6s }
194
+ .reveal .seal { animation: stamp .55s cubic-bezier(.3,1.6,.5,1) .62s both; }
195
+ .reveal .truth { animation: rise .5s var(--ease) 1.05s both; }
196
+ .reveal .bars, .reveal .gate, .reveal .footnote { animation: fade .6s var(--ease) .9s both; }
197
+ .verdict.deliberating .juror { animation: pulse 1.4s ease-in-out infinite; }
198
+ }
199
+ @keyframes flip { from { transform: rotateX(-88deg); opacity:0 } to { transform:none; opacity:1 } }
200
+ @keyframes rise { from { transform: translateY(14px); opacity:0 } to { transform:none; opacity:1 } }
201
+ @keyframes fade { from { opacity:0 } to { opacity:1 } }
202
+ @keyframes stamp { 0% { transform: scale(1.9) rotate(-18deg); opacity:0 } 60% { opacity:1 }
203
+ 100% { transform: scale(1) rotate(-8deg); opacity:1 } }
204
+ @keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.55 } }
205
+ .seal { transform: rotate(-8deg); }
206
+
207
+ footer { margin-top:44px; padding-top:18px; border-top:1px solid var(--line); font-size:.82rem; color:var(--muted);
208
+ display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; }
209
+ footer a { color:var(--ink-2); }
210
+
211
+ @media (max-width: 900px) {
212
+ .hero { grid-template-columns:1fr; gap:14px; margin-top:26px; }
213
+ .court { grid-template-columns:1fr; }
214
+ .verdict { position:relative; top:auto; min-height:0; }
215
+ .empty { min-height:220px; }
216
+ .domains .chip { flex:1 1 calc(50% - 4px); }
217
+ }
218
+ @media (max-width: 420px) {
219
+ .wrap { padding:16px 14px 40px; }
220
+ .case, .verdict { padding:16px; }
221
+ .jurors { grid-template-columns:1fr; gap:6px; }
222
+ .juror { min-height:0; display:grid; grid-template-columns:minmax(0,1fr) auto; column-gap:12px; padding:9px 11px; }
223
+ .juror .who, .juror .role { grid-column:1; }
224
+ .juror .vote { grid-column:2; grid-row:1; margin:0; text-align:right; }
225
+ .juror .agree { grid-column:2; grid-row:2; margin:0; text-align:right; }
226
+ .seal { width:112px; height:112px; }
227
+ .seal .num b { font-size:1rem; }
228
+ }
229
  </style>
230
  </head>
231
  <body>
232
  <div class="wrap">
233
+
234
+ <header class="mast">
235
+ <div class="brand">Quorum<small>a jury of tiny open models</small></div>
236
+ <nav aria-label="Project links">
237
+ <a href="https://github.com/DataAgent-Lab/Quorum" target="_blank" rel="noopener">GitHub</a>
238
+ <a href="https://huggingface.co/collections/DataAgent/quorum-6abb3255e0fab20d66add39b" target="_blank" rel="noopener">Models</a>
239
+ <a href="https://huggingface.co/DataAgent/Quorum-Reader-Qwen3-4B-Adapter" target="_blank" rel="noopener">24-shot adapter</a>
240
+ </nav>
241
+ </header>
242
+
243
+ <section class="hero">
244
+ <h1>Ask the <em>jury</em>.</h1>
245
+ <p>Three sub-1B open models read a message and each casts a vote. Quorum returns the verdict and a confidence
246
+ you can act on — no training, no paid API. Every example is a real test sentence from a public benchmark, so
247
+ you can see when the jury gets it right, and when it doesn't.</p>
248
+ </section>
249
+
250
+ <main class="court">
251
+ <section class="case card" aria-labelledby="case-title">
252
+ <h2 id="case-title" class="step"><b>01</b> Choose a domain</h2>
253
+ <div class="domains" id="domains" role="group" aria-label="Domains"></div>
254
+
255
+ <div class="step" style="margin-top:22px"><b>02</b> The message</div>
256
+ <div class="fieldhead">
257
+ <span class="tag" id="msg-tag"></span>
258
+ <button class="linkbtn" id="another" type="button">🎲 Another real sentence</button>
259
+ </div>
260
+ <label for="msg" class="sr">Message</label>
261
+ <textarea id="msg" rows="2" spellcheck="false"></textarea>
262
+
263
+ <div class="step" style="margin-top:22px"><b>03</b> Candidate labels</div>
264
+ <div id="labels-area"></div>
265
+ <div class="hint" id="labels-hint"></div>
266
+
267
+ <div class="actions">
268
+ <button class="btn" id="go" type="button">Hear the verdict<span class="arrow">→</span></button>
269
+ <div class="privacy">Sentences you type yourself are kept on the demo server so repeats answer instantly.</div>
270
+ </div>
271
+ </section>
272
+
273
+ <section class="verdict card" id="verdict" aria-live="polite" aria-label="Verdict">
274
+ <div class="empty" id="empty">
275
+ <div><div class="glyph">⚖</div><p>Pick a domain and hear the verdict. The three jurors vote, then Quorum
276
+ rules — and the dataset tells you whether it was right.</p></div>
277
+ </div>
278
+ </section>
279
+ </main>
280
 
281
  <footer>
282
+ <div>Test sentences: Banking77, CLINC150, HWU64, MASSIVE, MTOP, SNIPS and Bitext — public research datasets.</div>
283
+ <div><a href="https://github.com/DataAgent-Lab/Quorum/issues" target="_blank" rel="noopener">Want this on your own labels? Start a conversation →</a></div>
 
284
  </footer>
285
  </div>
286
 
287
+ <style>.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}</style>
288
  <script>
289
+ let DATA = null; // space/samples.json — real benchmark test sentences + description labels
290
+ const DEFAULT_API = "https://quorum-api.idataagent.com";
291
+ // ?api= is honoured only when the page itself runs locally (testing / self-hosting). On the public page a crafted
292
+ // link must never redirect what visitors type to another server.
293
+ const LOCAL_PAGE = ["localhost", "127.0.0.1", "[::1]", ""].includes(location.hostname);
294
+ const API_BASE = ((LOCAL_PAGE && new URLSearchParams(location.search).get("api")) || DEFAULT_API).replace(/\/$/, "");
295
+ const HIT_TIMEOUT = 30000, LIVE_TIMEOUT = 180000; // a cold start (models load on demand) takes ~30-90 s
296
+ const LIVE_LIMIT = 50; // labels the server computes live; larger sets are cache-only
297
+ const MEMBERS = [["PrismNLI-0.4B", "NLI reader"], ["bge-large", "Embedder · large"], ["bge-base", "Embedder · base"]];
298
+ const OWN = { key: "own", title: "Your own labels", dataset: "free text",
299
+ msg: "I was charged twice for the same order and want my money back",
300
+ labels: ["billing / double charge", "refund request", "order status", "cancel subscription", "technical bug", "general question"] };
301
+
302
+ const $ = id => document.getElementById(id);
303
+ const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
304
+ const state = { ds: null, sample: null, edited: false, showLabels: false };
305
+
306
+ /* ---------- domains ---------- */
307
+ function renderDomains() {
308
+ const all = [...DATA.domains, OWN];
309
+ $("domains").innerHTML = all.map(d => `<button type="button" class="chip${d.key === "own" ? " own" : ""}" data-key="${d.key}"
310
+ aria-pressed="false"><span class="t">${esc(d.title)}</span><span class="s">${d.key === "own" ? "type anything"
311
+ : `${esc(d.dataset)} · ${d.labels.length}`}</span></button>`).join("");
312
+ $("domains").querySelectorAll(".chip").forEach(b => b.onclick = () => select(b.dataset.key));
313
+ }
314
+ function select(key) {
315
+ state.ds = key === "own" ? OWN : DATA.domains.find(d => d.key === key);
316
+ $("domains").querySelectorAll(".chip").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.key === key)));
317
+ state.showLabels = false;
318
+ if (key === "own") { state.sample = null; $("msg").value = OWN.msg; }
319
+ else draw();
320
+ renderLabels(); syncMessageState();
321
+ }
322
+ function draw() {
323
+ const d = state.ds, prev = state.sample;
324
+ let s; do { s = d.samples[Math.floor(Math.random() * d.samples.length)]; } while (d.samples.length > 1 && s === prev);
325
+ state.sample = s; $("msg").value = s.t; state.edited = false; syncMessageState();
326
+ }
327
+
328
+ /* ---------- labels ---------- */
329
+ function currentLabels() { // [{name, desc, send}] — `send` is the exact string sent to the API
330
+ if (state.ds.key === "own") {
331
+ const seen = new Set();
332
+ return ($("own-labels") ? $("own-labels").value : OWN.labels.join("\n")).split("\n").map(s => s.trim())
333
+ .filter(s => s && !seen.has(s) && seen.add(s)).map(s => ({ name: s, desc: null, send: s }));
334
  }
335
+ return state.ds.labels.map(l => ({ name: l.name, desc: l.desc, send: l.desc || l.name }));
336
+ }
337
+ function renderLabels() {
338
+ const d = state.ds;
339
+ if (d.key === "own") {
340
+ $("labels-area").innerHTML = `<label for="own-labels" class="sr">Candidate labels</label>
341
+ <textarea id="own-labels" spellcheck="false">${esc(OWN.labels.join("\n"))}</textarea>`;
342
+ $("own-labels").addEventListener("input", syncMessageState);
343
+ return;
344
+ }
345
+ const n = d.labels.length;
346
+ $("labels-area").innerHTML = `<div class="labels-sum"><div><span class="n">${n}</span>
347
+ <span class="what">intents · from ${esc(d.dataset)}</span></div>
348
+ <button class="linkbtn" id="toggle-labels" type="button" aria-expanded="${state.showLabels}">${state.showLabels ? "Hide" : "Show"} labels</button></div>
349
+ ${state.showLabels ? `<ul class="labels-list">${d.labels.map(l => `<li><span class="nm">${esc(l.name)}</span>${l.desc
350
+ ? `<span class="ds">${esc(l.desc)}</span>` : ""}</li>`).join("")}</ul>` : ""}`;
351
+ $("toggle-labels").onclick = () => { state.showLabels = !state.showLabels; renderLabels(); };
352
+ }
353
+
354
+ /* ---------- message state ---------- */
355
+ function syncMessageState() {
356
+ const d = state.ds, own = d.key === "own";
357
+ state.edited = !own && state.sample && $("msg").value.trim() !== state.sample.t;
358
+ const tag = $("msg-tag");
359
+ if (own) { tag.className = "tag edited"; tag.textContent = "Your sentence"; }
360
+ else if (state.edited) { tag.className = "tag edited"; tag.textContent = "Edited — no longer a test sentence"; }
361
+ else { tag.className = "tag"; tag.textContent = "Real test sentence"; }
362
+ $("another").hidden = own;
363
+ const n = currentLabels().length, hint = $("labels-hint"), go = $("go");
364
+ hint.className = "hint"; hint.textContent = ""; go.disabled = false;
365
+ if (own && n > LIVE_LIMIT) { hint.className = "hint warn"; hint.textContent = `Up to ${LIVE_LIMIT} labels can be computed live — you have ${n}.`; go.disabled = true; }
366
+ else if (own && n < 2) { hint.className = "hint warn"; hint.textContent = "Add at least 2 labels, one per line."; go.disabled = true; }
367
+ else if (state.edited && n > LIVE_LIMIT) {
368
+ hint.className = "hint warn";
369
+ hint.textContent = `Edited sentences are computed live, which this small server does for up to ${LIVE_LIMIT} labels — ${d.dataset} has ${n}. Use 🎲 for another real sentence, or switch to “Your own labels”.`;
370
+ go.disabled = true;
371
+ } else if (!own) hint.textContent = "The jury ranks every one of these labels for the message.";
372
+ }
373
+
374
+ /* ---------- deciding ---------- */
375
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
376
+ function isOwnDefault() {
377
+ return state.ds.key === "own" && $("msg").value.trim() === OWN.msg &&
378
+ currentLabels().map(l => l.send).join("\n") === OWN.labels.join("\n");
379
+ }
380
+ async function decide() {
381
+ const labels = currentLabels(), message = $("msg").value.trim();
382
+ if (!message || $("go").disabled) return;
383
+ const gold = (!state.edited && state.sample) ? state.sample.g : null;
384
+ const maybeLive = state.ds.key === "own" ? !isOwnDefault() : state.edited; // everything else is pre-computed
385
+ $("go").disabled = true;
386
+ showDeliberating();
387
+ const timed = async () => { // the footnote reports the real response time, not the deliberation animation
388
+ const t0 = performance.now(), out = await apiPredict(message, labels, maybeLive);
389
+ return { ...out, secs: (performance.now() - t0) / 1000 };
390
+ };
391
+ try {
392
+ const [{ res, source, secs }] = await Promise.all([timed(), sleep(350)]);
393
+ showVerdict(res, labels, gold, source, secs);
394
+ } catch (e) { showError(e); }
395
+ finally { stopClock(); syncMessageState(); }
396
+ }
397
+ async function post(message, labels, timeout) {
398
+ const ctrl = new AbortController(), timer = setTimeout(() => ctrl.abort(), timeout);
399
+ try {
400
+ const r = await fetch(API_BASE + "/predict", { method: "POST", headers: { "content-type": "application/json" },
401
+ body: JSON.stringify({ message, labels: labels.map(l => l.send) }), signal: ctrl.signal });
402
+ if (!r.ok) {
403
+ let detail = ""; try { detail = (await r.json()).detail || ""; } catch (_) {}
404
+ throw Object.assign(new Error(typeof detail === "string" ? detail : ""), { status: r.status });
405
  }
406
+ return { res: await r.json(), source: r.headers.get("X-Quorum-Cache") || "miss" };
407
+ } finally { clearTimeout(timer); }
408
+ }
409
+ async function modelAwake() {
410
+ try { const r = await fetch(API_BASE + "/health", { signal: AbortSignal.timeout(5000) });
411
+ return (await r.json()).model_loaded; } catch (_) { return null; }
412
+ }
413
+ async function apiPredict(message, labels, maybeLive) {
414
+ const timeout = maybeLive ? LIVE_TIMEOUT : HIT_TIMEOUT;
415
+ if (maybeLive && (await modelAwake()) === false) showWaking();
416
+ try { return await post(message, labels, timeout); }
417
+ catch (e) {
418
+ // Timeouts, network errors, 503 (busy / restarting) and 504 are worth one retry: the server keeps a result it
419
+ // finishes, so the second attempt is usually answered from the cache.
420
+ const retriable = e.name === "AbortError" || e.name === "TypeError" || e.status === 503 || e.status === 504;
421
+ if (!retriable) throw e;
422
+ showWaking("Still working — trying once more…");
423
+ await sleep(3000);
424
+ return await post(message, labels, timeout);
425
  }
426
+ }
427
+
428
+ /* ---------- rendering ---------- */
429
+ function jurorsHTML(members, verdictSend, byS) {
430
+ return `<div class="jurors">${MEMBERS.map(([m, role], i) => {
431
+ const mv = members && members[i];
432
+ if (!mv) return `<div class="juror thinking"><div class="who">${m}</div><div class="role">${role}</div>
433
+ <div class="vote"><span class="dots"><span></span><span></span><span></span></span></div></div>`;
434
+ const lab = byS.get(mv.pick), agree = mv.pick === verdictSend;
435
+ return `<div class="juror"><div class="who">${m}</div><div class="role">${role}</div>
436
+ <div class="vote">${esc(lab ? lab.name : mv.pick)}</div>
437
+ <div class="agree ${agree ? "y" : "n"}">${agree ? "✓ with the verdict" : "≠ dissents"}</div></div>`;
438
+ }).join("")}</div>`;
439
+ }
440
+ let clock = null;
441
+ function showDeliberating() {
442
+ const v = $("verdict"); v.className = "verdict card deliberating";
443
+ v.innerHTML = jurorsHTML(null) + `<div id="delib-note"><p class="hint" style="margin-top:16px">The jury is deliberating…</p></div>`;
444
+ }
445
+ function showWaking(headline) {
446
+ const note = $("delib-note"); if (!note) return;
447
+ const t0 = clock ? clock.t0 : performance.now();
448
+ note.innerHTML = `<div class="waking"><b>${esc(headline || "Waking the jury up")}</b>
449
+ <p>The models sleep when the demo is quiet and load on demand, so this first answer can take a minute or two.
450
+ Answers after that are quick, and anything already computed comes back instantly.</p>
451
+ <div class="clock" id="clock">0 s</div></div>`;
452
+ stopClock();
453
+ clock = { t0, id: setInterval(() => { const c = $("clock"); if (c) c.textContent = `${Math.round((performance.now() - t0) / 1000)} s`; }, 1000) };
454
+ }
455
+ function stopClock() { if (clock) { clearInterval(clock.id); clock = null; } }
456
+ function showVerdict(res, labels, gold, source, secs) {
457
+ const byS = new Map(labels.map((l, i) => [l.send, { ...l, i }]));
458
+ const top = byS.get(res.label), pct = res.confidence * 100;
459
+ const entries = Object.entries(res.distribution);
460
+ const truth = gold == null ? `<div class="truth na"><span class="ic">i</span><div>Not a test sentence, so there is no
461
+ reference answer to check against.</div></div>`
462
+ : gold === top.i ? `<div class="truth ok"><span class="ic">✓</span><div>Correct — this is the dataset's answer.
463
+ <small>${accLine()}</small></div></div>`
464
+ : `<div class="truth no"><span class="ic">✗</span><div>The dataset's answer is <b>${esc(labels[gold].name)}</b>.
465
+ <small>${accLine()}</small></div></div>`;
466
+ const bar = ([s, p], i) => { const l = byS.get(s);
467
+ return `<div class="bar-row${i === 0 ? " top" : ""}"><div class="lab" title="${esc(l.desc || l.name)}">${esc(l.name)}${l.desc
468
+ ? `<span>${esc(l.desc)}</span>` : ""}</div><div class="pct">${(p * 100).toFixed(1)}%</div>
469
+ <div class="track"><div class="fill" data-w="${Math.max(1.5, p * 100)}"></div></div></div>`; };
470
+ const v = $("verdict"); v.className = "verdict card reveal";
471
+ v.innerHTML = jurorsHTML(res.members, res.label, byS) + `
472
+ <div class="ruling"><div><div class="eyebrow">The verdict</div><div class="name">${esc(top.name)}</div>
473
+ ${top.desc ? `<div class="desc">${esc(top.desc)}</div>` : ""}</div>
474
+ <div class="seal" aria-label="confidence ${pct.toFixed(1)} percent">${sealSVG()}<div class="num"><div>
475
+ <b id="conf">0.0%</b><i>confidence</i></div></div></div></div>
476
+ ${truth}
477
+ <div class="bars">${entries.slice(0, 5).map(bar).join("")}<div id="more-bars" hidden>${entries.slice(5).map((e, i) => bar(e, i + 5)).join("")}</div>
478
+ ${entries.length > 5 ? `<button class="linkbtn more" id="more" type="button" aria-expanded="false">Show all ${entries.length} labels</button>` : ""}</div>
479
+ <div class="gate"><div class="gate-head"><span>Auto-act when confidence ≥ <b id="tauv">70</b>%</span><span>else hand to a human</span></div>
480
+ <input type="range" id="tau" min="0" max="100" value="70" aria-label="Confidence threshold" />
481
+ <div class="decision" id="decision"></div></div>
482
+ <div class="footnote"><span>${source === "hit" ? "answered from the cache" : "computed live"} in ${secs.toFixed(2)} s</span>
483
+ <span>${state.ds.key === "own" ? "zero-shot · your labels" : `zero-shot · ${esc(state.ds.dataset)} · label descriptions`}</span></div>`;
484
+ requestAnimationFrame(() => setTimeout(() => v.querySelectorAll(".bars > .bar-row .fill").forEach((f, i) =>
485
+ setTimeout(() => f.style.width = f.dataset.w + "%", matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 950 + i * 70)), 0));
486
+ countUp($("conf"), pct);
487
+ if ($("more")) $("more").onclick = () => { const m = $("more-bars"), open = m.hidden; m.hidden = !open;
488
+ $("more").setAttribute("aria-expanded", String(open)); $("more").textContent = open ? "Show top 5" : `Show all ${entries.length} labels`;
489
+ m.querySelectorAll(".fill").forEach(f => f.style.width = open ? f.dataset.w + "%" : 0); };
490
+ const gate = () => { const t = +$("tau").value; $("tauv").textContent = t; const d = $("decision");
491
+ d.className = "decision " + (pct >= t ? "act" : "esc");
492
+ d.textContent = pct >= t ? `Auto-act — ${pct.toFixed(1)}% ≥ ${t}%` : `Escalate to a human — ${pct.toFixed(1)}% < ${t}%`; };
493
+ $("tau").oninput = gate; gate();
494
+ }
495
+ function accLine() { const d = state.ds;
496
+ return `Quorum gets ${(d.accuracy * 100).toFixed(1)}% of ${d.n_test.toLocaleString()} ${esc(d.dataset)} test sentences right (zero-shot).`; }
497
+ function sealSVG() {
498
+ const id = "c" + Math.random().toString(36).slice(2, 7);
499
+ return `<svg viewBox="0 0 120 120" aria-hidden="true"><defs><path id="${id}" d="M60,60 m-48,0 a48,48 0 1,1 96,0 a48,48 0 1,1 -96,0"/></defs>
500
+ <circle cx="60" cy="60" r="58" fill="none" stroke="var(--seal)" stroke-width="1.4"/>
501
+ <circle cx="60" cy="60" r="40" fill="var(--seal-soft)" stroke="var(--seal)" stroke-width="1"/>
502
+ <text font-family="IBM Plex Mono, monospace" font-size="7.4" letter-spacing="2.9" fill="var(--seal)">
503
+ <textPath href="#${id}">QUORUM · VERDICT · QUORUM · VERDICT ·</textPath></text></svg>`;
504
+ }
505
+ function countUp(el, target) {
506
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) { el.textContent = target.toFixed(1) + "%"; return; }
507
+ const t0 = performance.now() + 620, dur = 800;
508
+ const tick = now => { const k = Math.min(1, Math.max(0, (now - t0) / dur)), e = 1 - Math.pow(1 - k, 3);
509
+ el.textContent = (target * e).toFixed(1) + "%"; if (k < 1) requestAnimationFrame(tick); };
510
+ requestAnimationFrame(tick);
511
+ }
512
+ function showError(e) {
513
+ const v = $("verdict"); v.className = "verdict card";
514
+ const msg = e && e.status === 422 && e.message ? e.message
515
+ : "The jury couldn't answer just now. The demo runs on a small shared server, so please try again in a moment.";
516
+ v.innerHTML = `<div class="err">${esc(msg)}</div>${e && e.status === 422 ? "" :
517
+ `<button class="btn" id="retry" type="button">Try again<span class="arrow">→</span></button>`}`;
518
+ if ($("retry")) $("retry").onclick = decide;
519
+ }
520
+
521
+ /* ---------- boot ---------- */
522
+ $("msg").addEventListener("input", syncMessageState);
523
+ $("another").onclick = draw;
524
+ $("go").onclick = decide;
525
+ $("go").disabled = true;
526
+ fetch("samples.json").then(r => { if (!r.ok) throw new Error(r.status); return r.json(); }).then(d => {
527
+ DATA = d; renderDomains(); select("banking77");
528
+ }).catch(() => {
529
+ $("verdict").innerHTML = `<div class="err">The examples could not be loaded — please refresh the page.</div>`;
530
+ });
531
  </script>
532
  </body>
533
  </html>
samples.json ADDED
The diff for this file is too large to render. See raw diff