Upload folder using huggingface_hub
Browse files- Dockerfile +25 -0
- app.py +63 -0
- fixtures/clinical.json +64 -0
- fixtures/org.json +49 -0
- fixtures/review.json +64 -0
- fixtures/support.json +61 -0
- model.py +105 -0
- static/brand.css +161 -0
- static/index.html +185 -0
- static/strip.js +49 -0
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 & attributes</h2><p>Attribute groups attached per span</p></div>
|
| 58 |
+
<button type="button" class="code-toggle" id="code-toggle"></> 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
| 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 |
+
}
|