enoriega commited on
Commit
8252e17
·
0 Parent(s):

Initial commit with the app

Browse files
Files changed (8) hide show
  1. .gitignore +10 -0
  2. .python-version +1 -0
  3. README.md +45 -0
  4. backend/__init__.py +1 -0
  5. backend/app.py +607 -0
  6. main.py +5 -0
  7. pyproject.toml +28 -0
  8. uv.lock +0 -0
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.13
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NER UI
2
+
3
+ Demo project that exposes a Hugging Face NER experience through:
4
+
5
+ - `backend/`: a FastAPI API that fetches available model revisions from the Hugging Face Hub and runs a token-classification pipeline
6
+ - a mounted Gradio UI at `/` for model selection, text submission, and highlighted entity rendering
7
+
8
+ ## Requirements
9
+
10
+ - Python 3.13+
11
+ - `uv`
12
+
13
+ ## Install
14
+
15
+ Install Python dependencies:
16
+
17
+ ```bash
18
+ uv sync
19
+ ```
20
+
21
+ ## Run the app
22
+
23
+ Start the FastAPI server with the mounted Gradio frontend:
24
+
25
+ ```bash
26
+ uv run ner-ui
27
+ ```
28
+
29
+ Then open `http://localhost:8000/`.
30
+
31
+ API endpoints:
32
+
33
+ - `GET /api/health`
34
+ - `GET /api/models/revisions?model_name=dslim/bert-base-NER`
35
+ - `POST /api/ner`
36
+
37
+ Request body for `/api/ner`:
38
+
39
+ ```json
40
+ {
41
+ "text": "Hugging Face Inc. is based in New York City.",
42
+ "model_name": "dslim/bert-base-NER",
43
+ "revision": "main"
44
+ }
45
+ ```
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Backend package for the NER demo application."""
backend/app.py ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from functools import lru_cache
5
+ from typing import Any
6
+ from html import escape
7
+
8
+ import gradio as gr
9
+ from fastapi import FastAPI, HTTPException, Query
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from huggingface_hub import HfApi
12
+ from pydantic import BaseModel, Field
13
+ from transformers import pipeline
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+ DEFAULT_MODEL_NAME = "gyorilab/variants-ner-modernbert-base"
17
+ DEFAULT_REVISION = "main"
18
+ DEFAULT_TEXT = (
19
+ "In our cohort we analyzed variants affecting neuronal signaling and cytoskeletal stability. Sequencing identified several recurrent protein mutations including TP53 R248W, EGFR L858R, and a truncating MAPT Q336* variant predicted to disrupt microtubule binding. At the DNA level we detected substitutions such as TP53 c.743G>A and EGFR c.2573T>G, along with a small deletion c.152_153del causing a frameshift in downstream transcripts. Structural variation analysis further revealed a copy number gain consistent with chr7:55,019,017-55,242,524, overlapping the EGFR locus and resembling CNV strings commonly reported in PubTator3-style annotations. Population-associated polymorphisms were also present, including rs429358 and rs7412 within APOE, as well as rs1801133 in MTHFR. Together, these protein-altering mutations, nucleotide substitutions, and regional copy number changes suggest combined effects on cellular stress responses, signaling pathways, and metabolic regulation in the studied samples."
20
+ )
21
+
22
+ LABEL_COLORS = [
23
+ "#d4a373",
24
+ "#2a9d8f",
25
+ "#577590",
26
+ "#e76f51",
27
+ "#8d99ae",
28
+ "#6a994e",
29
+ ]
30
+ GRADIO_THEME = gr.themes.Base(
31
+ primary_hue="cyan",
32
+ secondary_hue="blue",
33
+ neutral_hue="slate",
34
+ radius_size="md",
35
+ )
36
+ GRADIO_CSS = """
37
+ :root {
38
+ --page-bg: #f3f3f3;
39
+ --page-text: #66717d;
40
+ --muted-text: #7d858e;
41
+ --accent-text: #4ea9b8;
42
+ --panel-bg: #ffffff;
43
+ --panel-border: #d5dadd;
44
+ --panel-shadow: 0 1px 2px rgba(76, 93, 108, 0.08);
45
+ --card-bg: #f7f8f8;
46
+ --card-border: #dde3e6;
47
+ --field-bg: #f7f8f8;
48
+ --field-border: #d7dde0;
49
+ --field-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
50
+ --highlight-text: #20303a;
51
+ --empty-bg: #eaf4f6;
52
+ --link-color: #6aa0d6;
53
+ --secondary-button-bg: #eef3f4;
54
+ --secondary-button-border: #d3dade;
55
+ --table-header-bg: #eef3f4;
56
+ --table-row-alt: #fafbfb;
57
+ --focus-ring: 0 0 0 3px rgba(83, 173, 188, 0.14);
58
+ }
59
+
60
+ .dark,
61
+ body.dark,
62
+ html.dark,
63
+ [data-theme="dark"],
64
+ .dark .gradio-container,
65
+ body.dark .gradio-container,
66
+ html.dark .gradio-container,
67
+ [data-theme="dark"] .gradio-container,
68
+ .gradio-container.dark {
69
+ --page-bg: #111922;
70
+ --page-text: #d4dde4;
71
+ --muted-text: #aeb8c2;
72
+ --accent-text: #73c6d2;
73
+ --panel-bg: #18232f;
74
+ --panel-border: #2a3b4b;
75
+ --panel-shadow: 0 10px 30px rgba(0, 0, 0, 0.22);
76
+ --card-bg: #111b24;
77
+ --card-border: #2a3b4b;
78
+ --field-bg: #213142;
79
+ --field-border: #314658;
80
+ --field-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
81
+ --highlight-text: #0d1821;
82
+ --empty-bg: #17313a;
83
+ --link-color: #8bb8e8;
84
+ --secondary-button-bg: #223342;
85
+ --secondary-button-border: #314658;
86
+ --table-header-bg: #213142;
87
+ --table-row-alt: #15212c;
88
+ --focus-ring: 0 0 0 3px rgba(115, 198, 210, 0.2);
89
+ }
90
+
91
+ body, .gradio-container {
92
+ background: var(--page-bg);
93
+ }
94
+ .gradio-container {
95
+ font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
96
+ color: var(--page-text);
97
+ transition: background-color 0.2s ease, color 0.2s ease;
98
+ }
99
+ .hero {
100
+ padding: 1rem 0 0.4rem;
101
+ }
102
+ .eyebrow {
103
+ margin: 0 0 0.5rem;
104
+ text-transform: uppercase;
105
+ letter-spacing: 0.12em;
106
+ font-size: 0.74rem;
107
+ color: var(--accent-text);
108
+ }
109
+ .hero-title {
110
+ margin: 0;
111
+ font-size: clamp(1.85rem, 3.2vw, 2.9rem);
112
+ line-height: 1.05;
113
+ max-width: 18ch;
114
+ font-weight: 600;
115
+ letter-spacing: -0.02em;
116
+ color: var(--page-text);
117
+ }
118
+ .hero-copy {
119
+ max-width: 60ch;
120
+ color: var(--muted-text);
121
+ }
122
+ .panel {
123
+ border: 1px solid var(--panel-border);
124
+ background: var(--panel-bg);
125
+ box-shadow: var(--panel-shadow);
126
+ border-radius: 10px;
127
+ padding: 1rem;
128
+ transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
129
+ }
130
+ .panel > .gap {
131
+ gap: 0.9rem !important;
132
+ }
133
+ .result-card {
134
+ min-height: 180px;
135
+ padding: 1.1rem;
136
+ border-radius: 8px;
137
+ background: var(--card-bg);
138
+ border: 1px solid var(--card-border);
139
+ transition: background-color 0.2s ease, border-color 0.2s ease;
140
+ }
141
+ .result-text {
142
+ margin: 0;
143
+ font-size: 1rem;
144
+ white-space: pre-wrap;
145
+ line-height: 1.65;
146
+ color: var(--page-text);
147
+ }
148
+ .entity-highlight {
149
+ display: inline-flex;
150
+ align-items: center;
151
+ gap: 0.35rem;
152
+ margin: 0 0.08rem;
153
+ padding: 0.15rem 0.35rem;
154
+ border-radius: 6px;
155
+ color: var(--highlight-text);
156
+ }
157
+ .entity-chip {
158
+ font-size: 0.72rem;
159
+ font-weight: 700;
160
+ text-transform: uppercase;
161
+ }
162
+ .empty-state {
163
+ margin: 0;
164
+ border-radius: 8px;
165
+ padding: 0.8rem 1rem;
166
+ background: var(--empty-bg);
167
+ color: var(--page-text);
168
+ }
169
+ .gradio-container a {
170
+ color: var(--link-color);
171
+ }
172
+ .gradio-container table {
173
+ color: var(--page-text);
174
+ }
175
+ [data-testid="block-label"] {
176
+ color: var(--page-text) !important;
177
+ font-size: 0.84rem !important;
178
+ font-weight: 600 !important;
179
+ letter-spacing: 0.01em;
180
+ }
181
+ [data-testid="textbox"],
182
+ [data-testid="dropdown"],
183
+ [data-testid="textbox"] > label,
184
+ [data-testid="dropdown"] > label,
185
+ [data-testid="dataframe"] {
186
+ background: transparent !important;
187
+ border: none !important;
188
+ box-shadow: none !important;
189
+ }
190
+ [data-testid="textbox"] textarea,
191
+ [data-testid="textbox"] input,
192
+ [data-testid="dropdown"] button {
193
+ background: var(--field-bg) !important;
194
+ color: var(--page-text) !important;
195
+ border: 1px solid var(--field-border) !important;
196
+ border-radius: 8px !important;
197
+ box-shadow: var(--field-shadow) !important;
198
+ }
199
+ [data-testid="textbox"] textarea,
200
+ [data-testid="textbox"] input {
201
+ padding: 0.8rem 0.9rem !important;
202
+ }
203
+ [data-testid="dropdown"] button {
204
+ min-height: 3rem !important;
205
+ }
206
+ [data-testid="textbox"] textarea:focus,
207
+ [data-testid="textbox"] input:focus,
208
+ [data-testid="dropdown"] button:focus,
209
+ [data-testid="dropdown"] button[aria-expanded="true"] {
210
+ border-color: var(--accent-text) !important;
211
+ box-shadow: var(--focus-ring) !important;
212
+ }
213
+ [data-testid="dropdown-options"] {
214
+ background: var(--panel-bg) !important;
215
+ border: 1px solid var(--field-border) !important;
216
+ border-radius: 8px !important;
217
+ box-shadow: 0 8px 24px rgba(76, 93, 108, 0.12) !important;
218
+ }
219
+ [data-testid="dropdown-options"] [role="option"] {
220
+ color: var(--page-text) !important;
221
+ }
222
+ [data-testid="dropdown-options"] [aria-selected="true"] {
223
+ background: var(--empty-bg) !important;
224
+ }
225
+ button.primary,
226
+ button.lg.primary {
227
+ background: #53adbc !important;
228
+ border: 1px solid #53adbc !important;
229
+ color: #ffffff !important;
230
+ border-radius: 8px !important;
231
+ box-shadow: none !important;
232
+ }
233
+ button.secondary,
234
+ button.lg.secondary {
235
+ background: var(--secondary-button-bg) !important;
236
+ border: 1px solid var(--secondary-button-border) !important;
237
+ color: var(--page-text) !important;
238
+ border-radius: 8px !important;
239
+ box-shadow: none !important;
240
+ }
241
+ button.primary:hover,
242
+ button.secondary:hover {
243
+ filter: brightness(0.98);
244
+ }
245
+ button.primary:focus,
246
+ button.secondary:focus {
247
+ box-shadow: var(--focus-ring) !important;
248
+ }
249
+ [data-testid="dataframe"] {
250
+ overflow: hidden !important;
251
+ border: 1px solid var(--field-border) !important;
252
+ border-radius: 8px !important;
253
+ background: var(--panel-bg) !important;
254
+ }
255
+ [data-testid="dataframe"] table {
256
+ background: var(--panel-bg) !important;
257
+ }
258
+ [data-testid="dataframe"] thead th {
259
+ background: var(--table-header-bg) !important;
260
+ color: var(--page-text) !important;
261
+ border-bottom: 1px solid var(--field-border) !important;
262
+ font-weight: 600 !important;
263
+ }
264
+ [data-testid="dataframe"] tbody td {
265
+ color: var(--page-text) !important;
266
+ background: var(--panel-bg) !important;
267
+ border-color: var(--card-border) !important;
268
+ }
269
+ [data-testid="dataframe"] tbody tr:nth-child(even) td {
270
+ background: var(--table-row-alt) !important;
271
+ }
272
+ [data-testid="markdown"] p,
273
+ .gr-markdown p {
274
+ color: var(--muted-text) !important;
275
+ }
276
+ """
277
+
278
+
279
+ class NerRequest(BaseModel):
280
+ text: str = Field(min_length=1, description="Input text to annotate.")
281
+ model_name: str = Field(min_length=1, description="Hugging Face model repo id.")
282
+ revision: str | None = Field(default=None, description="Optional model revision.")
283
+
284
+
285
+ class EntityPrediction(BaseModel):
286
+ label: str
287
+ score: float
288
+ start: int
289
+ end: int
290
+ text: str
291
+
292
+
293
+ class NerResponse(BaseModel):
294
+ text: str
295
+ model_name: str
296
+ revision: str | None
297
+ entities: list[EntityPrediction]
298
+
299
+
300
+ class ModelRevision(BaseModel):
301
+ name: str
302
+ kind: str
303
+
304
+
305
+ class ModelRevisionResponse(BaseModel):
306
+ model_name: str
307
+ revisions: list[ModelRevision]
308
+
309
+
310
+ def get_model_revisions_data(model_name: str) -> ModelRevisionResponse:
311
+ try:
312
+ refs = get_hf_api().list_repo_refs(model_name, repo_type="model")
313
+ except Exception as exc: # pragma: no cover - network/runtime integration
314
+ raise HTTPException(
315
+ status_code=502,
316
+ detail=f"Unable to fetch revisions for '{model_name}': {exc}",
317
+ ) from exc
318
+
319
+ revisions = [
320
+ ModelRevision(name=branch.name, kind="branch")
321
+ for branch in refs.branches
322
+ ]
323
+ revisions.extend(
324
+ ModelRevision(name=tag.name, kind="tag")
325
+ for tag in refs.tags
326
+ )
327
+
328
+ if not revisions:
329
+ revisions.append(ModelRevision(name=DEFAULT_REVISION, kind="branch"))
330
+
331
+ return ModelRevisionResponse(model_name=model_name, revisions=revisions)
332
+
333
+
334
+ def run_ner_inference(request: NerRequest) -> NerResponse:
335
+ try:
336
+ ner_pipeline = get_ner_pipeline(request.model_name, request.revision)
337
+ predictions = ner_pipeline(request.text)
338
+ except Exception as exc: # pragma: no cover - model/runtime integration
339
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
340
+
341
+ entities = [
342
+ EntityPrediction(
343
+ label=prediction["entity_group"],
344
+ score=float(prediction["score"]),
345
+ start=int(prediction["start"]),
346
+ end=int(prediction["end"]),
347
+ text=request.text[prediction["start"] : prediction["end"]],
348
+ )
349
+ for prediction in predictions
350
+ ]
351
+
352
+ return NerResponse(
353
+ text=request.text,
354
+ model_name=request.model_name,
355
+ revision=request.revision,
356
+ entities=entities,
357
+ )
358
+
359
+
360
+ def render_highlighted_html(text: str, entities: list[EntityPrediction]) -> str:
361
+ if not text:
362
+ return '<div class="result-card"><p class="empty-state">Enter text to annotate.</p></div>'
363
+
364
+ if not entities:
365
+ return (
366
+ '<div class="result-card">'
367
+ f'<p class="result-text">{escape(text)}</p>'
368
+ "</div>"
369
+ )
370
+
371
+ label_colors: dict[str, str] = {}
372
+ fragments: list[str] = []
373
+ cursor = 0
374
+
375
+ for entity in entities:
376
+ label_color = label_colors.setdefault(
377
+ entity.label,
378
+ LABEL_COLORS[len(label_colors) % len(LABEL_COLORS)],
379
+ )
380
+
381
+ if cursor < entity.start:
382
+ fragments.append(escape(text[cursor : entity.start]))
383
+
384
+ entity_text = escape(text[entity.start : entity.end])
385
+ entity_label = escape(entity.label)
386
+ fragments.append(
387
+ '<mark class="entity-highlight" '
388
+ f'style="background-color: {label_color};">'
389
+ f"{entity_text}"
390
+ f'<span class="entity-chip">{entity_label}</span>'
391
+ "</mark>"
392
+ )
393
+ cursor = entity.end
394
+
395
+ if cursor < len(text):
396
+ fragments.append(escape(text[cursor:]))
397
+
398
+ return (
399
+ '<div class="result-card">'
400
+ f'<p class="result-text">{"".join(fragments)}</p>'
401
+ "</div>"
402
+ )
403
+
404
+
405
+ def render_entity_table(entities: list[EntityPrediction]) -> list[list[str]]:
406
+ if not entities:
407
+ return []
408
+
409
+ return [
410
+ [
411
+ entity.label,
412
+ entity.text,
413
+ str(entity.start),
414
+ str(entity.end),
415
+ f"{entity.score * 100:.1f}%",
416
+ ]
417
+ for entity in entities
418
+ ]
419
+
420
+
421
+ def load_revisions_for_ui(model_name: str, selected_revision: str | None) -> tuple[gr.Dropdown, str]:
422
+ trimmed_model_name = model_name.strip()
423
+ if not trimmed_model_name:
424
+ return gr.Dropdown(choices=[], value=None), "Enter a Hugging Face model id to load revisions."
425
+
426
+ revision_response = get_model_revisions_data(trimmed_model_name)
427
+ revision_choices = [
428
+ (f"{item.name} ({item.kind})", item.name)
429
+ for item in revision_response.revisions
430
+ ]
431
+ revision_names = [item.name for item in revision_response.revisions]
432
+ revision_value = selected_revision if selected_revision in revision_names else revision_names[0]
433
+
434
+ return (
435
+ gr.Dropdown(choices=revision_choices, value=revision_value),
436
+ f"Loaded {len(revision_choices)} revision(s) for `{trimmed_model_name}`.",
437
+ )
438
+
439
+
440
+ def run_ner_for_ui(text: str, model_name: str, revision: str | None) -> tuple[str, list[list[str]], str]:
441
+ trimmed_model_name = model_name.strip()
442
+ trimmed_text = text.strip()
443
+
444
+ if not trimmed_model_name:
445
+ raise gr.Error("Model name is required.")
446
+ if not trimmed_text:
447
+ raise gr.Error("Input text is required.")
448
+
449
+ response = run_ner_inference(
450
+ NerRequest(
451
+ text=trimmed_text,
452
+ model_name=trimmed_model_name,
453
+ revision=revision or DEFAULT_REVISION,
454
+ )
455
+ )
456
+
457
+ return (
458
+ render_highlighted_html(response.text, response.entities),
459
+ render_entity_table(response.entities),
460
+ f"Found {len(response.entities)} entity span(s) using `{response.model_name}` at revision `{response.revision or DEFAULT_REVISION}`.",
461
+ )
462
+
463
+
464
+ def build_gradio_app() -> gr.Blocks:
465
+ with gr.Blocks(title="NER UI") as demo:
466
+ gr.HTML(
467
+ """
468
+ <section class="hero">
469
+ <p class="eyebrow">Transformer NER Demo</p>
470
+ <h1 class="hero-title">Run Hugging Face token classification models against live text.</h1>
471
+ <p class="hero-copy">
472
+ Pick a model, choose a revision from the Hub, submit text, and inspect the
473
+ predicted named entities with class-based highlighting.
474
+ </p>
475
+ </section>
476
+ """
477
+ )
478
+
479
+ with gr.Row(equal_height=False):
480
+ with gr.Column(scale=4, elem_classes=["panel"]):
481
+ model_name = gr.Textbox(
482
+ label="Model name",
483
+ value=DEFAULT_MODEL_NAME,
484
+ placeholder="dslim/bert-base-NER",
485
+ )
486
+ with gr.Row():
487
+ revision = gr.Dropdown(
488
+ label="Revision",
489
+ choices=[],
490
+ value=None,
491
+ allow_custom_value=False,
492
+ )
493
+ load_revisions = gr.Button("Load revisions", variant="secondary")
494
+ text = gr.Textbox(
495
+ label="Input text",
496
+ value=DEFAULT_TEXT,
497
+ lines=10,
498
+ placeholder="Paste a sentence or paragraph to annotate.",
499
+ )
500
+ run_button = gr.Button("Run NER", variant="primary")
501
+
502
+ with gr.Column(scale=5, elem_classes=["panel"]):
503
+ status = gr.Markdown("Loading available revisions...")
504
+ highlighted = gr.HTML(
505
+ '<div class="result-card"><p class="empty-state">Run NER to see highlighted predictions.</p></div>',
506
+ label="Highlighted text",
507
+ )
508
+ entity_table = gr.Dataframe(
509
+ headers=["Label", "Text", "Start", "End", "Score"],
510
+ datatype=["str", "str", "str", "str", "str"],
511
+ row_count=(0, "dynamic"),
512
+ column_count=(5, "fixed"),
513
+ interactive=False,
514
+ label="Predicted entities",
515
+ )
516
+
517
+ revision_event = load_revisions.click(
518
+ fn=load_revisions_for_ui,
519
+ inputs=[model_name, revision],
520
+ outputs=[revision, status],
521
+ api_name=False,
522
+ )
523
+ model_name.submit(
524
+ fn=load_revisions_for_ui,
525
+ inputs=[model_name, revision],
526
+ outputs=[revision, status],
527
+ api_name=False,
528
+ )
529
+ model_name.blur(
530
+ fn=load_revisions_for_ui,
531
+ inputs=[model_name, revision],
532
+ outputs=[revision, status],
533
+ api_name=False,
534
+ )
535
+ run_button.click(
536
+ fn=run_ner_for_ui,
537
+ inputs=[text, model_name, revision],
538
+ outputs=[highlighted, entity_table, status],
539
+ api_name=False,
540
+ )
541
+ text.submit(
542
+ fn=run_ner_for_ui,
543
+ inputs=[text, model_name, revision],
544
+ outputs=[highlighted, entity_table, status],
545
+ api_name=False,
546
+ )
547
+ demo.load(
548
+ fn=load_revisions_for_ui,
549
+ inputs=[model_name, revision],
550
+ outputs=[revision, status],
551
+ api_name=False,
552
+ )
553
+
554
+ return demo
555
+
556
+
557
+ @lru_cache(maxsize=1)
558
+ def get_hf_api() -> HfApi:
559
+ return HfApi()
560
+
561
+
562
+ @lru_cache(maxsize=8)
563
+ def get_ner_pipeline(model_name: str, revision: str | None):
564
+ LOGGER.info("Loading NER pipeline for model=%s revision=%s", model_name, revision)
565
+ return pipeline(
566
+ task="token-classification",
567
+ model=model_name,
568
+ revision=revision,
569
+ aggregation_strategy="simple",
570
+ )
571
+
572
+
573
+ def create_app() -> FastAPI:
574
+ app = FastAPI(title="NER UI", version="0.1.0")
575
+ app.add_middleware(
576
+ CORSMiddleware,
577
+ allow_origins=["*"],
578
+ allow_credentials=True,
579
+ allow_methods=["*"],
580
+ allow_headers=["*"],
581
+ )
582
+
583
+ @app.get("/api/health")
584
+ async def healthcheck() -> dict[str, str]:
585
+ return {"status": "ok"}
586
+
587
+ @app.get("/api/models/revisions", response_model=ModelRevisionResponse)
588
+ async def get_model_revisions(
589
+ model_name: str = Query(..., min_length=1, description="Hugging Face model repo id"),
590
+ ) -> ModelRevisionResponse:
591
+ return get_model_revisions_data(model_name)
592
+
593
+ @app.post("/api/ner", response_model=NerResponse)
594
+ async def run_ner(request: NerRequest) -> NerResponse:
595
+ return run_ner_inference(request)
596
+
597
+ demo = build_gradio_app()
598
+ return gr.mount_gradio_app(app, demo, path="/", theme=GRADIO_THEME, css=GRADIO_CSS)
599
+
600
+
601
+ app = create_app()
602
+
603
+
604
+ def main() -> None:
605
+ import uvicorn
606
+
607
+ uvicorn.run("backend.app:app", host="0.0.0.0", port=8000, reload=True)
main.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from backend.app import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
pyproject.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ner-ui"
7
+ version = "0.1.0"
8
+ description = "FastAPI and Gradio demo for Hugging Face NER models"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "fastapi>=0.115.12",
13
+ "gradio>=5.25.2",
14
+ "huggingface-hub>=0.31.1",
15
+ "jinja2>=3.1.6",
16
+ "torch>=2.7.0",
17
+ "transformers>=4.52.0",
18
+ "uvicorn>=0.34.0",
19
+ ]
20
+
21
+ [project.scripts]
22
+ ner-ui = "backend.app:main"
23
+
24
+ [tool.setuptools]
25
+ py-modules = ["main"]
26
+
27
+ [tool.setuptools.packages.find]
28
+ include = ["backend*"]
uv.lock ADDED
The diff for this file is too large to render. See raw diff