m-newhauser commited on
Commit
493b437
·
verified ·
1 Parent(s): d511ee1

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # Non-root user (HF Spaces convention; writable HOME for the HF cache the model
4
+ # is pulled into at boot).
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV HOME=/home/user \
8
+ PATH=/home/user/.local/bin:$PATH \
9
+ HF_HOME=/home/user/.cache/huggingface \
10
+ PYTHONUNBUFFERED=1
11
+
12
+ WORKDIR /home/user/app
13
+
14
+ # CPU-only torch (default PyPI wheel is CUDA and huge). Only needed for the real
15
+ # model; the mock path works on flask alone.
16
+ RUN pip install --no-cache-dir --user torch --index-url https://download.pytorch.org/whl/cpu
17
+ RUN pip install --no-cache-dir --user flask gunicorn
18
+
19
+ COPY --chown=user . .
20
+
21
+ EXPOSE 7860
22
+
23
+ # One worker (single shared model), threads for static/infer connections.
24
+ # No --preload: avoids the torch/OpenMP fork-after-threads deadlock.
25
+ CMD ["gunicorn", "-w", "1", "--threads", "8", "-k", "gthread", "-b", "0.0.0.0:7860", "--timeout", "0", "app:app"]
app.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GLiNER 2.5 demo server — identical for every demo.
2
+
3
+ Serves the static page, answers /api/ready, and POSTs {text, ...} to /api/infer,
4
+ which it delegates to model.infer(). All demo-specific logic lives in model.py.
5
+
6
+ python app.py # local dev on :7860
7
+ """
8
+
9
+ import os
10
+ import threading
11
+ import time
12
+
13
+ from flask import Flask, request, send_from_directory
14
+
15
+ import model
16
+
17
+ APP_DIR = os.path.dirname(os.path.abspath(__file__))
18
+ STATIC = os.path.join(APP_DIR, "static")
19
+
20
+ _LOCK = threading.Lock() # one inference at a time: CPU threads beat concurrency
21
+
22
+ print(f"[boot] {model.DEMO['title']} — mock={model.MOCK}", flush=True)
23
+ model.warmup() # load model / validate fixtures before first request
24
+ print("[boot] ready", flush=True)
25
+
26
+ app = Flask(__name__)
27
+
28
+
29
+ @app.route("/")
30
+ def index():
31
+ return send_from_directory(STATIC, "index.html")
32
+
33
+
34
+ @app.route("/<path:name>")
35
+ def static_file(name):
36
+ if name in ("brand.css", "strip.js", "favicon.svg"):
37
+ return send_from_directory(STATIC, name)
38
+ return ("not found", 404)
39
+
40
+
41
+ @app.route("/api/config")
42
+ def config():
43
+ """Demo content for the page: title, subtitle, example chips. No results."""
44
+ return {k: v for k, v in model.DEMO.items() if k != "internal"}
45
+
46
+
47
+ @app.route("/api/ready")
48
+ def ready():
49
+ return {"ready": True}
50
+
51
+
52
+ @app.route("/api/infer", methods=["POST"])
53
+ def infer():
54
+ data = request.get_json(silent=True) or {}
55
+ t0 = time.perf_counter()
56
+ with _LOCK:
57
+ out = model.infer(data)
58
+ out["ms"] = round((time.perf_counter() - t0) * 1000)
59
+ return out
60
+
61
+
62
+ if __name__ == "__main__":
63
+ app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 7860)), threaded=True)
fixtures/clinical.json ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "spans": [
3
+ {
4
+ "start": 15,
5
+ "end": 25,
6
+ "text": "chest pain",
7
+ "label": "condition",
8
+ "score": 0.94,
9
+ "attributes": {
10
+ "assertion": [
11
+ {
12
+ "label": "absent",
13
+ "score": 0.93
14
+ }
15
+ ]
16
+ }
17
+ },
18
+ {
19
+ "start": 42,
20
+ "end": 51,
21
+ "text": "pneumonia",
22
+ "label": "condition",
23
+ "score": 0.96,
24
+ "attributes": {
25
+ "assertion": [
26
+ {
27
+ "label": "absent",
28
+ "score": 0.95
29
+ }
30
+ ]
31
+ }
32
+ },
33
+ {
34
+ "start": 62,
35
+ "end": 73,
36
+ "text": "mild anemia",
37
+ "label": "condition",
38
+ "score": 0.87,
39
+ "attributes": {
40
+ "assertion": [
41
+ {
42
+ "label": "possible",
43
+ "score": 0.81
44
+ }
45
+ ]
46
+ }
47
+ },
48
+ {
49
+ "start": 86,
50
+ "end": 98,
51
+ "text": "hypertension",
52
+ "label": "condition",
53
+ "score": 0.95,
54
+ "attributes": {
55
+ "assertion": [
56
+ {
57
+ "label": "historical",
58
+ "score": 0.92
59
+ }
60
+ ]
61
+ }
62
+ }
63
+ ]
64
+ }
fixtures/org.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "spans": [
3
+ {
4
+ "start": 4,
5
+ "end": 13,
6
+ "text": "Maya Chen",
7
+ "label": "person",
8
+ "score": 0.97,
9
+ "attributes": {
10
+ "role": [
11
+ {
12
+ "label": "executive",
13
+ "score": 0.96
14
+ }
15
+ ]
16
+ }
17
+ },
18
+ {
19
+ "start": 33,
20
+ "end": 46,
21
+ "text": "Daniel Okafor",
22
+ "label": "person",
23
+ "score": 0.95,
24
+ "attributes": {
25
+ "role": [
26
+ {
27
+ "label": "executive",
28
+ "score": 0.93
29
+ }
30
+ ]
31
+ }
32
+ },
33
+ {
34
+ "start": 88,
35
+ "end": 98,
36
+ "text": "Priya Nair",
37
+ "label": "person",
38
+ "score": 0.94,
39
+ "attributes": {
40
+ "role": [
41
+ {
42
+ "label": "analyst",
43
+ "score": 0.95
44
+ }
45
+ ]
46
+ }
47
+ }
48
+ ]
49
+ }
fixtures/review.json ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "spans": [
3
+ {
4
+ "start": 4,
5
+ "end": 10,
6
+ "text": "screen",
7
+ "label": "product",
8
+ "score": 0.96,
9
+ "attributes": {
10
+ "sentiment": [
11
+ {
12
+ "label": "positive",
13
+ "score": 0.95
14
+ }
15
+ ]
16
+ }
17
+ },
18
+ {
19
+ "start": 31,
20
+ "end": 39,
21
+ "text": "keyboard",
22
+ "label": "product",
23
+ "score": 0.93,
24
+ "attributes": {
25
+ "sentiment": [
26
+ {
27
+ "label": "positive",
28
+ "score": 0.89
29
+ }
30
+ ]
31
+ }
32
+ },
33
+ {
34
+ "start": 61,
35
+ "end": 68,
36
+ "text": "battery",
37
+ "label": "product",
38
+ "score": 0.95,
39
+ "attributes": {
40
+ "sentiment": [
41
+ {
42
+ "label": "negative",
43
+ "score": 0.94
44
+ }
45
+ ]
46
+ }
47
+ },
48
+ {
49
+ "start": 94,
50
+ "end": 103,
51
+ "text": "fan noise",
52
+ "label": "product",
53
+ "score": 0.88,
54
+ "attributes": {
55
+ "sentiment": [
56
+ {
57
+ "label": "negative",
58
+ "score": 0.92
59
+ }
60
+ ]
61
+ }
62
+ }
63
+ ]
64
+ }
fixtures/support.json ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "spans": [
3
+ {
4
+ "start": 4,
5
+ "end": 17,
6
+ "text": "export button",
7
+ "label": "product",
8
+ "score": 0.9,
9
+ "attributes": {
10
+ "impact": [
11
+ {
12
+ "label": "data_loss",
13
+ "score": 0.88
14
+ },
15
+ {
16
+ "label": "blocks_work",
17
+ "score": 0.74
18
+ }
19
+ ]
20
+ }
21
+ },
22
+ {
23
+ "start": 27,
24
+ "end": 39,
25
+ "text": "deletes rows",
26
+ "label": "event",
27
+ "score": 0.84,
28
+ "attributes": {
29
+ "impact": [
30
+ {
31
+ "label": "data_loss",
32
+ "score": 0.91
33
+ },
34
+ {
35
+ "label": "blocks_work",
36
+ "score": 0.68
37
+ }
38
+ ]
39
+ }
40
+ },
41
+ {
42
+ "start": 86,
43
+ "end": 105,
44
+ "text": "losing audited data",
45
+ "label": "event",
46
+ "score": 0.82,
47
+ "attributes": {
48
+ "impact": [
49
+ {
50
+ "label": "data_loss",
51
+ "score": 0.93
52
+ },
53
+ {
54
+ "label": "security_risk",
55
+ "score": 0.55
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ ]
61
+ }
model.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Span attributes demo — entities carry per-span attribute groups (sentiment,
2
+ role, assertion, impact), single- or multi-label, scoped with applies_to.
3
+
4
+ MOCK until GLiNER 2.5 ships. Response contract:
5
+ {"spans": [{"start", "end", "text", "label", "score",
6
+ "attributes": {"<group>": [{"label": str, "score": float}]}}]}
7
+ Multi-label groups return several entries in the group list.
8
+ """
9
+
10
+ import json
11
+ import os
12
+
13
+ MOCK = os.environ.get("GLINER_MOCK", "1") == "1"
14
+ MODEL_ID = os.environ.get("MODEL_ID", "fastino/gliner2.5")
15
+ MODEL_URL = os.environ.get("MODEL_URL", f"https://huggingface.co/{MODEL_ID}")
16
+
17
+ DEMO = {
18
+ "title": "Sentiment and role, <em>per span</em>",
19
+ "subtitle": "Document classification labels the whole review. GLiNER 2.5 attaches <strong>attributes to every entity</strong> it extracts.",
20
+ "model_name": "GLiNER 2.5",
21
+ "model_variant": "0.3B parameters · f32 · CPU",
22
+ "code": (
23
+ 'schema = (model.create_schema()\n'
24
+ ' .entities({"product": "A product mentioned in the text",\n'
25
+ ' "person": "A person by name",\n'
26
+ ' "condition": "A medical condition"})\n'
27
+ ' .entity_attributes({\n'
28
+ ' "sentiment": AttributeGroup(\n'
29
+ ' labels=["positive", "neutral", "negative"],\n'
30
+ ' applies_to=["product"], qualify_labels=True),\n'
31
+ ' "role": AttributeGroup(\n'
32
+ ' labels=["executive", "employee", "customer", "analyst"],\n'
33
+ ' applies_to=["person"]),\n'
34
+ ' "assertion": AttributeGroup(\n'
35
+ ' labels=["present", "absent", "possible", "historical"],\n'
36
+ ' applies_to=["condition"]),\n'
37
+ ' "impact": AttributeGroup(\n'
38
+ ' labels=["blocks_work", "data_loss", "security_risk"],\n'
39
+ ' multi_label=True, threshold=0.40)}))\n'
40
+ 'model.extract(text, schema)'
41
+ ),
42
+ "examples": [
43
+ {"chip": "Mixed review",
44
+ "text": "The screen is gorgeous and the keyboard feels great, but the battery is disappointing and the fan noise is unacceptable.",
45
+ "fixture": "review.json"},
46
+ {"chip": "Org announcement",
47
+ "text": "CEO Maya Chen announced that CFO Daniel Okafor will lead the acquisition, while analyst Priya Nair briefed reporters.",
48
+ "fixture": "org.json"},
49
+ {"chip": "Clinical note",
50
+ "text": "Patient denies chest pain. No evidence of pneumonia. Possible mild anemia; history of hypertension noted.",
51
+ "fixture": "clinical.json"},
52
+ {"chip": "Support ticket",
53
+ "text": "The export button silently deletes rows, which blocked our quarterly report and risks losing audited data.",
54
+ "fixture": "support.json"},
55
+ ],
56
+ }
57
+
58
+ _HERE = os.path.dirname(os.path.abspath(__file__))
59
+
60
+
61
+ def warmup():
62
+ if MOCK:
63
+ for ex in DEMO["examples"]:
64
+ _fixture(ex["fixture"], ex["text"])
65
+ else:
66
+ _load_real()
67
+
68
+
69
+ def infer(data):
70
+ text = (data.get("text") or "").replace("\r", "")
71
+ if not text.strip():
72
+ return {"spans": []}
73
+ if MOCK:
74
+ return _infer_mock(text)
75
+ return _infer_real(text)
76
+
77
+
78
+ def _fixture(name, expect_text=None):
79
+ with open(os.path.join(_HERE, "fixtures", name)) as f:
80
+ out = json.load(f)
81
+ if expect_text is not None:
82
+ for sp in out.get("spans", []):
83
+ assert expect_text[sp["start"]:sp["end"]] == sp["text"], \
84
+ f"{name}: bad offsets for {sp['text']!r}"
85
+ return out
86
+
87
+
88
+ def _infer_mock(text):
89
+ for ex in DEMO["examples"]:
90
+ if ex["text"] == text:
91
+ return _fixture(ex["fixture"])
92
+ return {"spans": []}
93
+
94
+
95
+ _model = None
96
+
97
+
98
+ def _load_real():
99
+ global _model
100
+ from gliner import GLiNER
101
+ _model = GLiNER.from_pretrained(MODEL_ID, token=os.environ.get("HF_TOKEN"))
102
+
103
+
104
+ def _infer_real(text):
105
+ raise NotImplementedError("wire up GLiNER 2.5 here, keep the response shape")
static/brand.css ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Fastino System A brand tokens + shared chrome for GLiNER 2.5 demos.
2
+ Warm, flat, measured: linen background, orange accent, no gradients/shadows. */
3
+
4
+ @import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap");
5
+ /* 0xProto Nerd: not on Google Fonts, use the Nerd Fonts CDN, fall back gracefully */
6
+ @import url("https://www.nerdfonts.com/assets/css/webfont.css");
7
+
8
+ :root {
9
+ --linen: #F2ECE9; /* page background, always */
10
+ --greige: #EDE6E3; /* card fills */
11
+ --ash: #E9DEDB; /* hover, dividers */
12
+ --orange: #FF7345; /* Fastino accent: spans, key metrics */
13
+ --ink: #2E2729; /* headings, primary text */
14
+ --ink-soft: rgba(46, 39, 41, 0.62); /* secondary text */
15
+ --ink-faint: #8A7F7B; /* footnotes */
16
+ --warm-gray: #C4BBB7; /* base/comparison data, borders */
17
+ --card: #FBF8F6; /* lightest warm surface for input areas */
18
+ --ok: #5F8F6E; /* muted warm green, used sparingly */
19
+
20
+ --font-head: "Geist", "Helvetica Neue", Arial, sans-serif;
21
+ --font-body: "Geist", "Helvetica Neue", Arial, sans-serif;
22
+ --font-mono: "0xProto Nerd Font", "0xProto Nerd", "JetBrains Mono", ui-monospace, Menlo, monospace;
23
+
24
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
25
+
26
+ /* span label palette: warm, distinguishable, never color alone (always labeled) */
27
+ --c0: #FF7345; --c1: #C98A2D; --c2: #5F8F6E; --c3: #8A6FB8;
28
+ --c4: #4E7E96; --c5: #B85C74; --c6: #7D8A3C; --c7: #A0785A;
29
+ }
30
+
31
+ * { margin: 0; padding: 0; box-sizing: border-box; }
32
+
33
+ body {
34
+ font-family: var(--font-body);
35
+ background: var(--linen);
36
+ color: var(--ink);
37
+ font-size: 15px;
38
+ line-height: 1.55;
39
+ -webkit-font-smoothing: antialiased;
40
+ }
41
+
42
+ .wrap { max-width: 1060px; margin: 0 auto; padding: 28px 22px 48px; }
43
+
44
+ /* ---- hero ---- */
45
+ .site-intro h1 {
46
+ font-family: var(--font-head); font-weight: 700;
47
+ font-size: 34px; letter-spacing: -0.015em; line-height: 1.15;
48
+ }
49
+ .site-intro h1 em { font-style: normal; color: var(--orange); }
50
+ .site-intro .sub { margin-top: 6px; color: var(--ink-soft); font-size: 15px; }
51
+
52
+ /* ---- model strip ---- */
53
+ .model-strip {
54
+ display: flex; align-items: center; gap: 14px;
55
+ margin: 18px 0 22px; padding: 12px 16px;
56
+ background: var(--greige); border: 1px solid var(--ash); border-radius: 12px;
57
+ }
58
+ .model-glyph {
59
+ width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0;
60
+ background: var(--orange); color: #fff;
61
+ display: grid; place-items: center;
62
+ font-family: var(--font-head); font-weight: 700; font-size: 16px;
63
+ }
64
+ .model-name-row a { color: var(--ink); font-weight: 600; text-decoration: none; border-bottom: 1px solid var(--warm-gray); }
65
+ .model-name-row a:hover { border-bottom-color: var(--orange); }
66
+ .model-variant { margin-left: 8px; font-size: 12.5px; color: var(--ink-faint); font-family: var(--font-mono); }
67
+ .load-block { margin-left: auto; min-width: 220px; }
68
+ .load-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--ink-soft); margin-bottom: 5px; }
69
+ #load-percent { font-family: var(--font-mono); }
70
+ .load-track { height: 5px; background: var(--ash); border-radius: 99px; overflow: hidden; }
71
+ #load-progress { display: block; height: 100%; width: 0; background: var(--orange); transition: width 400ms var(--ease-out); }
72
+ .model-strip.is-ready #load-progress { background: var(--ok); }
73
+
74
+ /* ---- workspace cards ---- */
75
+ .workspace { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
76
+ .workspace.single { grid-template-columns: 1fr; }
77
+ .card {
78
+ background: var(--greige); border: 1px solid var(--ash); border-radius: 12px;
79
+ display: flex; flex-direction: column; min-height: 280px;
80
+ }
81
+ .panel-header {
82
+ display: flex; align-items: flex-start; justify-content: space-between;
83
+ padding: 13px 16px 10px; border-bottom: 1px solid var(--ash);
84
+ }
85
+ .panel-header h2 { font-size: 14px; font-weight: 600; }
86
+ .panel-header p { font-size: 12px; color: var(--ink-faint); margin-top: 1px; }
87
+
88
+ textarea, .editor {
89
+ flex: 1; width: 100%; border: 0; resize: vertical; min-height: 220px;
90
+ padding: 14px 16px; background: var(--card);
91
+ font: inherit; color: var(--ink); border-radius: 0 0 12px 12px;
92
+ }
93
+ textarea:focus, .editor:focus { outline: 2px solid var(--ash); outline-offset: -2px; }
94
+
95
+ .output-body {
96
+ flex: 1; padding: 14px 16px; background: var(--card); border-radius: 0 0 12px 12px;
97
+ font-size: 15px; line-height: 1.9; white-space: pre-wrap; word-break: break-word;
98
+ }
99
+ .output-empty { color: var(--ink-faint); font-size: 13.5px; }
100
+
101
+ /* ---- span highlighting (staggered reveal) ---- */
102
+ .span {
103
+ border-radius: 4px; padding: 1px 2px; margin: 0 1px;
104
+ background: color-mix(in srgb, var(--span-color) 22%, transparent);
105
+ border-bottom: 2px solid var(--span-color);
106
+ cursor: default; position: relative;
107
+ opacity: 0; transform: translateY(3px);
108
+ animation: span-in 380ms var(--ease-out) forwards;
109
+ animation-delay: calc(var(--i) * 55ms);
110
+ }
111
+ @keyframes span-in { to { opacity: 1; transform: translateY(0); } }
112
+
113
+ /* ---- legend / stats ---- */
114
+ .controls { padding: 10px 16px 13px; border-top: 1px solid var(--ash); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
115
+ .legend-chip {
116
+ display: inline-flex; align-items: center; gap: 6px;
117
+ font-size: 12px; font-weight: 500; color: var(--ink);
118
+ background: var(--linen); border: 1px solid var(--ash); border-radius: 99px; padding: 3px 10px;
119
+ }
120
+ .legend-swatch { width: 9px; height: 9px; border-radius: 3px; background: var(--span-color); }
121
+ .legend-count { font-family: var(--font-mono); color: var(--ink-faint); font-size: 11px; }
122
+
123
+ .stats { display: flex; gap: 18px; margin-left: auto; font-size: 12px; color: var(--ink-faint); }
124
+ .stats strong { font-family: var(--font-mono); font-weight: 500; color: var(--ink); margin-left: 4px; }
125
+
126
+ /* ---- example chips ---- */
127
+ .examples-panel { margin-top: 18px; }
128
+ .examples-label { font-size: 12px; color: var(--ink-faint); display: block; margin-bottom: 8px; }
129
+ .example-list { display: flex; flex-wrap: wrap; gap: 8px; }
130
+ .ex {
131
+ font: inherit; font-size: 13px; font-weight: 500; color: var(--ink);
132
+ background: var(--greige); border: 1px solid var(--ash); border-radius: 99px;
133
+ padding: 6px 14px; cursor: pointer;
134
+ transition: background 160ms var(--ease-out), border-color 160ms var(--ease-out);
135
+ }
136
+ .ex:hover { background: var(--ash); }
137
+ .ex.active { background: var(--ink); color: var(--linen); border-color: var(--ink); }
138
+
139
+ /* ---- view-as-code toggle ---- */
140
+ .code-toggle {
141
+ font: inherit; font-size: 12px; font-weight: 500; color: var(--ink-soft);
142
+ background: var(--linen); border: 1px solid var(--ash); border-radius: 8px;
143
+ padding: 4px 11px; cursor: pointer;
144
+ }
145
+ .code-toggle:hover { background: var(--ash); }
146
+ .code-panel {
147
+ display: none; margin: 0 16px 14px; padding: 12px 14px;
148
+ background: var(--ink); color: var(--linen); border-radius: 10px;
149
+ font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6;
150
+ white-space: pre; overflow-x: auto;
151
+ }
152
+ .code-panel.open { display: block; }
153
+
154
+ .footnote { margin-top: 22px; font-size: 12px; color: var(--ink-faint); }
155
+ .footnote a { color: var(--ink-soft); }
156
+
157
+ @media (max-width: 860px) { .workspace { grid-template-columns: 1fr; } .load-block { display: none; } }
158
+
159
+ @media (prefers-reduced-motion: reduce) {
160
+ *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
161
+ }
static/index.html ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Span attributes — GLiNER 2.5</title>
7
+ <link rel="stylesheet" href="brand.css">
8
+ <style>
9
+ /* demo-specific: attribute badges attached to spans */
10
+ .span-wrap { position: relative; }
11
+ .attr {
12
+ display: inline-block; vertical-align: super;
13
+ font-family: var(--font-mono); font-size: 10px; font-weight: 500;
14
+ color: #fff; background: var(--attr-color); border-radius: 4px;
15
+ padding: 0 4px; margin-left: 2px; line-height: 1.6;
16
+ opacity: 0; animation: span-in 320ms var(--ease-out) forwards;
17
+ animation-delay: calc(var(--i) * 55ms + 160ms);
18
+ }
19
+ .attr.pos { background: var(--ok); }
20
+ .attr.neg { background: #B85C5C; }
21
+ .attr.neu { background: var(--warm-gray); }
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <div class="wrap">
26
+ <header class="site-intro">
27
+ <h1 id="title"></h1>
28
+ <p class="sub" id="subtitle"></p>
29
+ </header>
30
+
31
+ <section class="model-strip is-loading" id="model-strip" aria-label="Model status">
32
+ <span class="model-glyph" aria-hidden="true">G</span>
33
+ <div class="model-copy">
34
+ <div class="model-name-row">
35
+ <a id="model-link" href="#" target="_blank" rel="noopener"></a>
36
+ <span class="model-variant" id="model-variant"></span>
37
+ </div>
38
+ </div>
39
+ <div class="load-block">
40
+ <div class="load-meta">
41
+ <span id="status" role="status" aria-live="polite">Connecting…</span>
42
+ <span id="load-percent">0%</span>
43
+ </div>
44
+ <div class="load-track"><span id="load-progress"></span></div>
45
+ </div>
46
+ </section>
47
+
48
+ <main>
49
+ <div class="workspace">
50
+ <section class="card">
51
+ <div class="panel-header"><div><h2>Text</h2><p>Edit or pick an example below</p></div></div>
52
+ <textarea id="input" spellcheck="false"></textarea>
53
+ </section>
54
+
55
+ <section class="card">
56
+ <div class="panel-header">
57
+ <div><h2>Spans &amp; attributes</h2><p>Attribute groups attached per span</p></div>
58
+ <button type="button" class="code-toggle" id="code-toggle">&lt;/&gt; schema</button>
59
+ </div>
60
+ <pre class="code-panel" id="code-panel"></pre>
61
+ <div id="output" class="output-body"><div class="output-empty">Extracted spans will appear here.</div></div>
62
+ <div class="controls">
63
+ <div class="legend" id="legend" style="display:contents"></div>
64
+ <div class="stats">
65
+ <span>Latency<strong id="stat-ms">—</strong></span>
66
+ <span>Spans<strong id="stat-n">—</strong></span>
67
+ </div>
68
+ </div>
69
+ </section>
70
+ </div>
71
+
72
+ <section class="examples-panel" aria-label="Examples">
73
+ <span class="examples-label">Examples</span>
74
+ <div class="example-list" id="examples"></div>
75
+ </section>
76
+
77
+ <p class="footnote">
78
+ Demo data. Attribute groups are scoped with <code>applies_to</code>: sentiment on products,
79
+ role on people, assertion on conditions. GLiNER 2.5 by
80
+ <a href="https://fastino.ai" target="_blank" rel="noopener">Fastino</a>.
81
+ </p>
82
+ </main>
83
+ </div>
84
+
85
+ <script src="strip.js"></script>
86
+ <script>
87
+ const $ = (id) => document.getElementById(id);
88
+ const esc = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
89
+ let READY = false, timer = null, version = 0, labels = [];
90
+
91
+ // sentiment groups get semantic badge colors; everything else uses the group palette
92
+ const ATTR_TONE = { positive: "pos", present: "pos", executive: "pos",
93
+ negative: "neg", absent: "neg", data_loss: "neg", security_risk: "neg",
94
+ neutral: "neu", historical: "neu" };
95
+
96
+ function attrBadges(attributes, i) {
97
+ let html = "";
98
+ for (const [group, entries] of Object.entries(attributes || {})) {
99
+ for (const e of entries) {
100
+ const tone = ATTR_TONE[e.label] || "";
101
+ html += `<span class="attr ${tone}" style="--i:${i}" title="${esc(group)} · ${e.score.toFixed(2)}">${esc(e.label)}</span>`;
102
+ }
103
+ }
104
+ return html;
105
+ }
106
+
107
+ function render(text, spans) {
108
+ let html = "", prev = 0, i = 0;
109
+ for (const sp of spans) {
110
+ html += esc(text.slice(prev, sp.start));
111
+ const c = `var(--c${labels.indexOf(sp.label) % 8})`;
112
+ const tip = `${sp.label} · ${sp.score.toFixed(2)}`;
113
+ html += `<span class="span-wrap"><span class="span" style="--span-color:${c};--i:${i}" title="${esc(tip)}">${esc(sp.text)}</span>${attrBadges(sp.attributes, i)}</span>`;
114
+ prev = sp.end; i++;
115
+ }
116
+ return html + esc(text.slice(prev));
117
+ }
118
+
119
+ function renderLegend(spans) {
120
+ const counts = {};
121
+ for (const sp of spans) counts[sp.label] = (counts[sp.label] || 0) + 1;
122
+ $("legend").innerHTML = Object.entries(counts).map(([label, n]) =>
123
+ `<span class="legend-chip" style="--span-color:var(--c${labels.indexOf(label) % 8})">
124
+ <span class="legend-swatch"></span>${esc(label)}<span class="legend-count">${n}</span>
125
+ </span>`).join("");
126
+ }
127
+
128
+ async function infer() {
129
+ if (!READY) return;
130
+ const v = ++version, text = $("input").value;
131
+ if (!text.trim()) {
132
+ $("output").innerHTML = '<div class="output-empty">Extracted spans will appear here.</div>';
133
+ $("legend").innerHTML = ""; $("stat-ms").textContent = "—"; $("stat-n").textContent = "—";
134
+ return;
135
+ }
136
+ let data;
137
+ try {
138
+ const r = await fetch("api/infer", { method: "POST",
139
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
140
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
141
+ data = await r.json();
142
+ } catch (e) { return; }
143
+ if (v !== version) return;
144
+ const spans = data.spans || [];
145
+ labels = [...new Set([...labels, ...spans.map((s) => s.label)])];
146
+ $("output").innerHTML = render(text, spans);
147
+ renderLegend(spans);
148
+ $("stat-ms").textContent = `${data.ms} ms`;
149
+ $("stat-n").textContent = spans.length;
150
+ }
151
+
152
+ function inferSoon(delay = 300) { clearTimeout(timer); timer = setTimeout(infer, delay); }
153
+ $("input").addEventListener("input", () => inferSoon());
154
+ $("code-toggle").addEventListener("click", () => $("code-panel").classList.toggle("open"));
155
+
156
+ (async () => {
157
+ const cfg = await fetchConfig();
158
+ document.title = `${cfg.title.replace(/<[^>]+>/g, "")} — GLiNER 2.5`;
159
+ $("title").innerHTML = cfg.title;
160
+ $("subtitle").innerHTML = cfg.subtitle;
161
+ $("model-link").textContent = cfg.model_name;
162
+ $("model-link").href = cfg.model_url || "#";
163
+ $("model-variant").textContent = cfg.model_variant || "";
164
+ $("code-panel").textContent = cfg.code || "";
165
+
166
+ cfg.examples.forEach((ex, i) => {
167
+ const b = document.createElement("button");
168
+ b.type = "button"; b.className = "ex" + (i === 0 ? " active" : "");
169
+ b.textContent = ex.chip;
170
+ b.addEventListener("click", () => {
171
+ document.querySelectorAll(".ex").forEach((x) => x.classList.toggle("active", x === b));
172
+ $("input").value = ex.text;
173
+ inferSoon(40);
174
+ });
175
+ $("examples").append(b);
176
+ });
177
+
178
+ if (cfg.examples[0]) $("input").value = cfg.examples[0].text;
179
+ READY = await waitForServer();
180
+ stripSet(READY ? "Model ready" : "Model unavailable", READY ? "ready" : "error", 100);
181
+ if (READY) infer();
182
+ })();
183
+ </script>
184
+ </body>
185
+ </html>
static/strip.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // strip.js — shared plumbing for every GLiNER 2.5 demo page.
2
+ // 1. __sign shim: private HF Spaces sign the iframe URL; Safari/incognito block
3
+ // the third-party cookie, so carry the token on same-origin API requests.
4
+ // 2. readiness poll: drives the model strip (loading -> preparing -> ready).
5
+ // 3. fetchConfig(): title/subtitle/examples, so the page is data-driven.
6
+
7
+ (() => {
8
+ const sign = new URLSearchParams(location.search).get("__sign");
9
+ if (!sign) return;
10
+ const orig = self.fetch.bind(self);
11
+ self.fetch = (input, init) => {
12
+ try {
13
+ const u = new URL(typeof input === "string" ? input : input.url, location.href);
14
+ if (u.origin === location.origin && !u.searchParams.has("__sign")) {
15
+ u.searchParams.set("__sign", sign);
16
+ input = u.toString();
17
+ }
18
+ } catch (e) { /* non-URL input: pass through */ }
19
+ return orig(input, init);
20
+ };
21
+ })();
22
+
23
+ function stripSet(message, tone, pct) {
24
+ const strip = document.getElementById("model-strip");
25
+ document.getElementById("status").textContent = message;
26
+ strip.className = `model-strip is-${tone}`;
27
+ if (pct != null) {
28
+ document.getElementById("load-progress").style.width = `${pct}%`;
29
+ document.getElementById("load-percent").textContent = `${Math.round(pct)}%`;
30
+ }
31
+ }
32
+
33
+ async function waitForServer() {
34
+ stripSet("Connecting to server…", "loading", 12);
35
+ for (let attempt = 0; attempt < 300; attempt++) {
36
+ try {
37
+ const r = await fetch("api/ready", { cache: "no-store" });
38
+ if (r.ok && (await r.json()).ready) return true;
39
+ } catch (e) { /* still starting */ }
40
+ stripSet("Warming up the model…", "preparing", Math.min(92, 12 + attempt * 4));
41
+ await new Promise((r) => setTimeout(r, 1000));
42
+ }
43
+ return false;
44
+ }
45
+
46
+ async function fetchConfig() {
47
+ const r = await fetch("api/config", { cache: "no-store" });
48
+ return r.json();
49
+ }