Spaces:
Running
Running
CyberFox commited on
Commit ·
ffbc075
0
Parent(s):
feat: show DBpedia links in entity cards
Browse files- README.md +27 -0
- app.py +534 -0
- requirements.txt +1 -0
README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Content Analysis v3
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.34.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
python_version: "3.11"
|
| 12 |
+
short_description: Multilingual NER + NED Entity Linking (EN/IT/FR/DE/ES)
|
| 13 |
+
env:
|
| 14 |
+
- GRADIO_SSR_MODE=false
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# Entity Extraction & Linking [FREE TOOL]
|
| 18 |
+
|
| 19 |
+
Elevate your website's performance with our AI-powered entity extraction and disambiguation tool.
|
| 20 |
+
|
| 21 |
+
Ready to turbocharge your SEO efforts and see your organic search performance soar? Look no further than our next-generation AI-powered entity linker — now with multilingual support across **five languages**!
|
| 22 |
+
|
| 23 |
+
With our intelligent solution powered by **GLiNER** and **BGE-M3**, you can analyze your content — whether raw text or any URL — and instantly identify all relevant entities within it. Each entity is automatically linked to its corresponding **Wikidata entry**, complete with descriptions and confidence scores.
|
| 24 |
+
|
| 25 |
+
This powerful combination helps search engines like Google better understand your content and rank it higher in search results. But that's not all! With precise entity disambiguation, our tool distinguishes between entities that share the same name, ensuring accurate knowledge graph connections that amplify your SEO benefits.
|
| 26 |
+
|
| 27 |
+
**Supported languages:** English 🇬🇧 · Italian 🇮🇹 · French 🇫🇷 · German 🇩🇪 · Spanish 🇪🇸
|
app.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Content Analysis v3 — Hugging Face Space Demo.
|
| 2 |
+
|
| 3 |
+
Interactive multilingual entity analysis with highlighted text
|
| 4 |
+
and entity cards. Supports text input and URL extraction.
|
| 5 |
+
|
| 6 |
+
Requires a WordLift API key for authentication.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
os.environ["GRADIO_SSR_MODE"] = "false"
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
import requests
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Configuration
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
API_BASE = "https://wordlift-lab--content-analysis-v3-web-app.modal.run"
|
| 19 |
+
|
| 20 |
+
# Entity type → color mapping (for highlighting)
|
| 21 |
+
TYPE_COLORS = {
|
| 22 |
+
"Person": "#6366f1", # Indigo
|
| 23 |
+
"Organization": "#0ea5e9", # Sky blue
|
| 24 |
+
"Company": "#0ea5e9",
|
| 25 |
+
"City": "#10b981", # Emerald
|
| 26 |
+
"Country": "#10b981",
|
| 27 |
+
"Place": "#10b981",
|
| 28 |
+
"Location": "#10b981",
|
| 29 |
+
"Brand": "#f59e0b", # Amber
|
| 30 |
+
"Product": "#f59e0b",
|
| 31 |
+
"Date": "#8b5cf6", # Violet
|
| 32 |
+
"Event": "#ec4899", # Pink
|
| 33 |
+
"Movie": "#f43f5e", # Rose
|
| 34 |
+
"Book": "#f43f5e",
|
| 35 |
+
"Song": "#f43f5e",
|
| 36 |
+
"CreativeWork": "#f43f5e",
|
| 37 |
+
"MedicalCondition": "#14b8a6", # Teal
|
| 38 |
+
"Drug": "#14b8a6",
|
| 39 |
+
"SportsTeam": "#0ea5e9",
|
| 40 |
+
"EducationalOrganization": "#0ea5e9",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
DEFAULT_COLOR = "#64748b" # Slate
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# API calls
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
def analyze_text_api(text: str, api_key: str, language=None, confidence: float = 0.5) -> dict:
|
| 49 |
+
"""Call the Content Analysis v3 text analysis API."""
|
| 50 |
+
payload = {"text": text, "confidence": confidence}
|
| 51 |
+
if language and language != "auto":
|
| 52 |
+
payload["language"] = language
|
| 53 |
+
headers = {"Authorization": f"Key {api_key}"}
|
| 54 |
+
try:
|
| 55 |
+
resp = requests.post(f"{API_BASE}/analyze/text", json=payload, headers=headers, timeout=120)
|
| 56 |
+
resp.raise_for_status()
|
| 57 |
+
return resp.json()
|
| 58 |
+
except requests.exceptions.HTTPError as e:
|
| 59 |
+
if e.response is not None and e.response.status_code == 401:
|
| 60 |
+
return {"error": "Invalid WordLift API key. Get yours at https://wordlift.io"}
|
| 61 |
+
return {"error": str(e), "entities": []}
|
| 62 |
+
except requests.exceptions.RequestException as e:
|
| 63 |
+
return {"error": str(e), "entities": []}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def analyze_url_api(url: str, api_key: str, language=None, confidence: float = 0.5) -> dict:
|
| 67 |
+
"""Call the Content Analysis v3 URL analysis API (extraction happens server-side)."""
|
| 68 |
+
payload = {"url": url, "confidence": confidence}
|
| 69 |
+
if language and language != "auto":
|
| 70 |
+
payload["language"] = language
|
| 71 |
+
headers = {"Authorization": f"Key {api_key}"}
|
| 72 |
+
try:
|
| 73 |
+
resp = requests.post(f"{API_BASE}/analyze/url", json=payload, headers=headers, timeout=120)
|
| 74 |
+
resp.raise_for_status()
|
| 75 |
+
return resp.json()
|
| 76 |
+
except requests.exceptions.HTTPError as e:
|
| 77 |
+
if e.response is not None and e.response.status_code == 401:
|
| 78 |
+
return {"error": "Invalid WordLift API key. Get yours at https://wordlift.io"}
|
| 79 |
+
return {"error": str(e), "entities": []}
|
| 80 |
+
except requests.exceptions.RequestException as e:
|
| 81 |
+
return {"error": str(e), "entities": []}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# Rendering
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
def build_highlighted_html(text: str, entities: list[dict]) -> str:
|
| 88 |
+
"""Build HTML with highlighted entity spans and hover tooltips."""
|
| 89 |
+
if not entities:
|
| 90 |
+
return f'<div class="analyzed-text">{_escape(text)}</div>'
|
| 91 |
+
|
| 92 |
+
# Sort entities by start position (reverse for safe insertion)
|
| 93 |
+
sorted_ents = sorted(entities, key=lambda e: e["start"])
|
| 94 |
+
|
| 95 |
+
# Build segments
|
| 96 |
+
segments = []
|
| 97 |
+
last_end = 0
|
| 98 |
+
|
| 99 |
+
for ent in sorted_ents:
|
| 100 |
+
start = ent["start"]
|
| 101 |
+
end = ent["end"]
|
| 102 |
+
|
| 103 |
+
# Skip overlapping entities
|
| 104 |
+
if start < last_end:
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
# Text before entity
|
| 108 |
+
if start > last_end:
|
| 109 |
+
segments.append(_escape(text[last_end:start]))
|
| 110 |
+
|
| 111 |
+
# Entity span with tooltip
|
| 112 |
+
color = TYPE_COLORS.get(ent["label"], DEFAULT_COLOR)
|
| 113 |
+
score = ent.get("score", 0)
|
| 114 |
+
entity_id = ent.get("entity_id", "")
|
| 115 |
+
entity_label = ent.get("entity_label", "")
|
| 116 |
+
entity_desc = ent.get("entity_description", "")
|
| 117 |
+
disambig = ent.get("disambiguation_score")
|
| 118 |
+
|
| 119 |
+
tooltip_parts = [f"Type: {ent['label']}", f"NER Score: {score:.2f}"]
|
| 120 |
+
if entity_id:
|
| 121 |
+
tooltip_parts.append(f"Entity: {entity_id}")
|
| 122 |
+
if entity_label:
|
| 123 |
+
tooltip_parts.append(f"Label: {entity_label}")
|
| 124 |
+
if disambig is not None:
|
| 125 |
+
tooltip_parts.append(f"Disambiguation: {disambig:.2f}")
|
| 126 |
+
tooltip = " | ".join(tooltip_parts)
|
| 127 |
+
|
| 128 |
+
entity_text = _escape(text[start:end])
|
| 129 |
+
|
| 130 |
+
# NED linked entity gets a special badge
|
| 131 |
+
ned_badge = ""
|
| 132 |
+
if entity_id:
|
| 133 |
+
ned_badge = (
|
| 134 |
+
f'<span style="font-size: 0.55em; font-weight: 700; '
|
| 135 |
+
f'color: #7c3aed; vertical-align: super; margin-left: 1px;">✓</span>'
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
segments.append(
|
| 139 |
+
f'<mark class="entity-highlight" style="background-color: {color}22; '
|
| 140 |
+
f'border-bottom: 2px solid {color}; color: inherit; padding: 2px 4px; '
|
| 141 |
+
f'border-radius: 3px; cursor: pointer;" title="{_escape(tooltip)}">'
|
| 142 |
+
f'{entity_text}'
|
| 143 |
+
f'<span class="entity-label" style="font-size: 0.65em; font-weight: 600; '
|
| 144 |
+
f'color: {color}; vertical-align: super; margin-left: 2px;">{ent["label"]}</span>'
|
| 145 |
+
f'{ned_badge}'
|
| 146 |
+
f'</mark>'
|
| 147 |
+
)
|
| 148 |
+
last_end = end
|
| 149 |
+
|
| 150 |
+
# Remaining text
|
| 151 |
+
if last_end < len(text):
|
| 152 |
+
segments.append(_escape(text[last_end:]))
|
| 153 |
+
|
| 154 |
+
return f'<div class="analyzed-text" style="font-size: 1.05em; line-height: 1.8; padding: 16px;">{"".join(segments)}</div>'
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def build_entity_cards_html(entities: list[dict]) -> str:
|
| 158 |
+
"""Build HTML entity cards showing details for each detected entity."""
|
| 159 |
+
if not entities:
|
| 160 |
+
return '<p style="color: #64748b; text-align: center; padding: 2em;">No entities detected.</p>'
|
| 161 |
+
|
| 162 |
+
# Deduplicate by text
|
| 163 |
+
seen = set()
|
| 164 |
+
unique = []
|
| 165 |
+
for ent in entities:
|
| 166 |
+
key = ent.get("text", "")
|
| 167 |
+
if key not in seen:
|
| 168 |
+
seen.add(key)
|
| 169 |
+
unique.append(ent)
|
| 170 |
+
|
| 171 |
+
cards_html = []
|
| 172 |
+
for ent in unique:
|
| 173 |
+
color = TYPE_COLORS.get(ent["label"], DEFAULT_COLOR)
|
| 174 |
+
score = ent.get("score", 0)
|
| 175 |
+
entity_id = ent.get("entity_id")
|
| 176 |
+
entity_label = ent.get("entity_label", "")
|
| 177 |
+
entity_desc = ent.get("entity_description", "")
|
| 178 |
+
disambig = ent.get("disambiguation_score")
|
| 179 |
+
|
| 180 |
+
# Badge
|
| 181 |
+
badge = (
|
| 182 |
+
f'<span style="display: inline-block; background: {color}; color: white; '
|
| 183 |
+
f'font-size: 0.7em; font-weight: 600; padding: 2px 8px; border-radius: 12px; '
|
| 184 |
+
f'letter-spacing: 0.5px; text-transform: uppercase;">{ent["label"]}</span>'
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# NED status badge
|
| 188 |
+
ned_status = ""
|
| 189 |
+
if entity_id:
|
| 190 |
+
ned_status = (
|
| 191 |
+
'<span style="display: inline-block; background: #7c3aed; color: white; '
|
| 192 |
+
'font-size: 0.65em; font-weight: 600; padding: 2px 6px; border-radius: 12px; '
|
| 193 |
+
'margin-left: 4px;">NED ✓</span>'
|
| 194 |
+
)
|
| 195 |
+
else:
|
| 196 |
+
ned_status = (
|
| 197 |
+
'<span style="display: inline-block; background: #e2e8f0; color: #64748b; '
|
| 198 |
+
'font-size: 0.65em; font-weight: 600; padding: 2px 6px; border-radius: 12px; '
|
| 199 |
+
'margin-left: 4px;">NER only</span>'
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# QID + DBpedia links
|
| 203 |
+
qid_html = ""
|
| 204 |
+
if entity_id:
|
| 205 |
+
dbpedia_uri = ent.get("dbpedia_uri", "")
|
| 206 |
+
dbpedia_link = ""
|
| 207 |
+
if dbpedia_uri:
|
| 208 |
+
dbpedia_link = (
|
| 209 |
+
f' <a href="{dbpedia_uri}" target="_blank" '
|
| 210 |
+
f'style="color: #64748b; text-decoration: none; font-size: 0.85em; '
|
| 211 |
+
f'font-weight: 500;">📚 DBpedia</a>'
|
| 212 |
+
)
|
| 213 |
+
qid_html = (
|
| 214 |
+
f'<a href="https://www.wikidata.org/wiki/{entity_id}" target="_blank" '
|
| 215 |
+
f'style="color: {color}; text-decoration: none; font-size: 0.85em; '
|
| 216 |
+
f'font-weight: 500;">🔗 {entity_id}</a>'
|
| 217 |
+
f'{dbpedia_link}'
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Scores bar
|
| 221 |
+
score_bar = _score_bar("NER", score, color)
|
| 222 |
+
disambig_bar = ""
|
| 223 |
+
if disambig is not None:
|
| 224 |
+
disambig_bar = _score_bar("NED", disambig, "#8b5cf6")
|
| 225 |
+
|
| 226 |
+
# Description
|
| 227 |
+
desc_html = ""
|
| 228 |
+
if entity_desc:
|
| 229 |
+
desc_html = f'<p style="color: #64748b; font-size: 0.85em; margin: 6px 0 0 0; line-height: 1.4;">{_escape(entity_desc)}</p>'
|
| 230 |
+
|
| 231 |
+
# Entity label (canonical from KB)
|
| 232 |
+
label_html = ""
|
| 233 |
+
if entity_label and entity_label != ent.get("text", ""):
|
| 234 |
+
label_html = f'<p style="color: #64748b; font-size: 0.8em; margin: 2px 0;">aka: {_escape(entity_label)}</p>'
|
| 235 |
+
|
| 236 |
+
card = f'''
|
| 237 |
+
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-left: 3px solid {color};
|
| 238 |
+
border-radius: 8px; padding: 14px 16px; margin-bottom: 8px;">
|
| 239 |
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
|
| 240 |
+
<div>
|
| 241 |
+
<span style="font-size: 1.05em; font-weight: 600; color: #1e293b;">{_escape(ent.get("text", ""))}</span>
|
| 242 |
+
{badge}
|
| 243 |
+
{ned_status}
|
| 244 |
+
</div>
|
| 245 |
+
{qid_html}
|
| 246 |
+
</div>
|
| 247 |
+
{label_html}
|
| 248 |
+
{desc_html}
|
| 249 |
+
<div style="margin-top: 8px;">
|
| 250 |
+
{score_bar}
|
| 251 |
+
{disambig_bar}
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
'''
|
| 255 |
+
cards_html.append(card)
|
| 256 |
+
|
| 257 |
+
return f'<div style="max-height: 500px; overflow-y: auto;">{"".join(cards_html)}</div>'
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def build_stats_html(result: dict) -> str:
|
| 261 |
+
"""Build summary stats HTML."""
|
| 262 |
+
entities = result.get("entities", [])
|
| 263 |
+
lang = result.get("language", "—")
|
| 264 |
+
time_ms = result.get("processing_time_ms", 0)
|
| 265 |
+
version = result.get("pipeline_version", "—")
|
| 266 |
+
|
| 267 |
+
# Count NER-only vs NED-linked
|
| 268 |
+
ned_count = sum(1 for e in entities if e.get("entity_id"))
|
| 269 |
+
ner_only_count = len(entities) - ned_count
|
| 270 |
+
|
| 271 |
+
# Type distribution
|
| 272 |
+
type_counts = {}
|
| 273 |
+
for ent in entities:
|
| 274 |
+
t = ent.get("label", "Unknown")
|
| 275 |
+
type_counts[t] = type_counts.get(t, 0) + 1
|
| 276 |
+
|
| 277 |
+
type_badges = " ".join(
|
| 278 |
+
f'<span style="background: {TYPE_COLORS.get(t, DEFAULT_COLOR)}33; color: {TYPE_COLORS.get(t, DEFAULT_COLOR)}; '
|
| 279 |
+
f'padding: 3px 10px; border-radius: 12px; font-size: 0.8em; font-weight: 500;">'
|
| 280 |
+
f'{t}: {c}</span>'
|
| 281 |
+
for t, c in sorted(type_counts.items(), key=lambda x: -x[1])
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
lang_flags = {"en": "🇬🇧", "it": "🇮🇹", "fr": "🇫🇷", "de": "🇩🇪", "es": "🇪🇸"}
|
| 285 |
+
flag = lang_flags.get(lang, "🌐")
|
| 286 |
+
|
| 287 |
+
return f'''
|
| 288 |
+
<div style="display: flex; gap: 16px; flex-wrap: wrap; padding: 8px 0;">
|
| 289 |
+
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
|
| 290 |
+
<div style="font-size: 1.5em; font-weight: 700; color: #4f46e5;">{len(entities)}</div>
|
| 291 |
+
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">Entities</div>
|
| 292 |
+
</div>
|
| 293 |
+
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
|
| 294 |
+
<div style="font-size: 1.5em; font-weight: 700; color: #7c3aed;">{ned_count}</div>
|
| 295 |
+
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">NED Linked</div>
|
| 296 |
+
</div>
|
| 297 |
+
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
|
| 298 |
+
<div style="font-size: 1.5em; font-weight: 700; color: #059669;">{flag} {lang.upper()}</div>
|
| 299 |
+
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">Language</div>
|
| 300 |
+
</div>
|
| 301 |
+
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
|
| 302 |
+
<div style="font-size: 1.5em; font-weight: 700; color: #d97706;">{time_ms:.0f}ms</div>
|
| 303 |
+
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">Latency</div>
|
| 304 |
+
</div>
|
| 305 |
+
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
|
| 306 |
+
<div style="font-size: 1.1em; font-weight: 600; color: #7c3aed;">{version}</div>
|
| 307 |
+
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">Pipeline</div>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
<div style="padding: 6px 0;">{type_badges}</div>
|
| 311 |
+
'''
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def _score_bar(label: str, score: float, color: str) -> str:
|
| 315 |
+
"""Render a mini score bar."""
|
| 316 |
+
pct = max(0, min(100, score * 100))
|
| 317 |
+
return (
|
| 318 |
+
f'<div style="display: flex; align-items: center; gap: 8px; margin: 3px 0;">'
|
| 319 |
+
f'<span style="font-size: 0.7em; color: #64748b; width: 28px; text-align: right;">{label}</span>'
|
| 320 |
+
f'<div style="flex: 1; background: #e2e8f0; border-radius: 4px; height: 6px; overflow: hidden;">'
|
| 321 |
+
f'<div style="width: {pct}%; background: {color}; height: 100%; border-radius: 4px;"></div>'
|
| 322 |
+
f'</div>'
|
| 323 |
+
f'<span style="font-size: 0.75em; color: #475569; width: 40px;">{score:.2f}</span>'
|
| 324 |
+
f'</div>'
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _escape(s: str) -> str:
|
| 329 |
+
"""HTML-escape a string."""
|
| 330 |
+
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ---------------------------------------------------------------------------
|
| 334 |
+
# Gradio handlers
|
| 335 |
+
# ---------------------------------------------------------------------------
|
| 336 |
+
def analyze_text_handler(text: str, language: str, confidence: float):
|
| 337 |
+
"""Handle text analysis."""
|
| 338 |
+
api_key = os.environ.get("WL_KEY", "").strip()
|
| 339 |
+
if not api_key:
|
| 340 |
+
err = "<p style='color:#f43f5e;text-align:center;'>⚠️ WL_KEY secret not configured.</p>"
|
| 341 |
+
return err, err, ""
|
| 342 |
+
|
| 343 |
+
if not text or not text.strip():
|
| 344 |
+
msg = "<p style='color:#94a3b8;text-align:center;'>Enter text to analyze.</p>"
|
| 345 |
+
return msg, msg, ""
|
| 346 |
+
|
| 347 |
+
try:
|
| 348 |
+
lang = language if language != "Auto-detect" else None
|
| 349 |
+
result = analyze_text_api(text, api_key, lang, confidence)
|
| 350 |
+
|
| 351 |
+
if "error" in result:
|
| 352 |
+
err_html = f"<p style='color: #f43f5e;'>API Error: {result['error']}</p>"
|
| 353 |
+
return err_html, err_html, ""
|
| 354 |
+
|
| 355 |
+
entities = result.get("entities", [])
|
| 356 |
+
stats = build_stats_html(result)
|
| 357 |
+
highlighted = build_highlighted_html(text, entities)
|
| 358 |
+
cards = build_entity_cards_html(entities)
|
| 359 |
+
|
| 360 |
+
return stats, highlighted, cards
|
| 361 |
+
except Exception as e:
|
| 362 |
+
err_html = f"<p style='color: #f43f5e;'>Error: {str(e)}</p>"
|
| 363 |
+
return err_html, err_html, ""
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def analyze_url_handler(url: str, language: str, confidence: float):
|
| 367 |
+
"""Handle URL analysis."""
|
| 368 |
+
api_key = os.environ.get("WL_KEY", "").strip()
|
| 369 |
+
if not api_key:
|
| 370 |
+
err = "<p style='color:#f43f5e;text-align:center;'>⚠️ WL_KEY secret not configured.</p>"
|
| 371 |
+
return err, err, "", ""
|
| 372 |
+
|
| 373 |
+
if not url or not url.strip():
|
| 374 |
+
msg = "<p style='color:#94a3b8;text-align:center;'>Enter a URL to analyze.</p>"
|
| 375 |
+
return msg, msg, "", ""
|
| 376 |
+
|
| 377 |
+
try:
|
| 378 |
+
lang = language if language != "Auto-detect" else None
|
| 379 |
+
result = analyze_url_api(url, api_key, lang, confidence)
|
| 380 |
+
|
| 381 |
+
if "error" in result:
|
| 382 |
+
err_html = f"<p style='color: #f43f5e;'>API Error: {result['error']}</p>"
|
| 383 |
+
return err_html, err_html, "", ""
|
| 384 |
+
|
| 385 |
+
entities = result.get("entities", [])
|
| 386 |
+
text = result.get("extracted_text", result.get("text", ""))
|
| 387 |
+
stats = build_stats_html(result)
|
| 388 |
+
highlighted = build_highlighted_html(text[:5000], entities)
|
| 389 |
+
cards = build_entity_cards_html(entities)
|
| 390 |
+
|
| 391 |
+
return stats, highlighted, cards, text[:2000]
|
| 392 |
+
except Exception as e:
|
| 393 |
+
err_html = f"<p style='color: #f43f5e;'>Error: {str(e)}</p>"
|
| 394 |
+
return err_html, err_html, "", ""
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
# ---------------------------------------------------------------------------
|
| 398 |
+
# Gradio UI
|
| 399 |
+
# ---------------------------------------------------------------------------
|
| 400 |
+
CUSTOM_CSS = """
|
| 401 |
+
.gradio-container {
|
| 402 |
+
max-width: 1200px !important;
|
| 403 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
|
| 404 |
+
}
|
| 405 |
+
.analyzed-text {
|
| 406 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 407 |
+
}
|
| 408 |
+
.entity-highlight:hover {
|
| 409 |
+
filter: brightness(0.95);
|
| 410 |
+
}
|
| 411 |
+
footer { display: none !important; }
|
| 412 |
+
"""
|
| 413 |
+
|
| 414 |
+
DESCRIPTION = """
|
| 415 |
+
<div style="text-align: center; padding: 8px 0;">
|
| 416 |
+
<p style="color: #475569; font-size: 0.95em; margin: 0;">
|
| 417 |
+
Multilingual Named Entity Recognition & Disambiguation powered by
|
| 418 |
+
<strong style="color: #4f46e5;">GLiNER</strong> +
|
| 419 |
+
<strong style="color: #7c3aed;">BGE-M3</strong> •
|
| 420 |
+
Supports <strong>EN</strong> 🇬🇧 <strong>IT</strong> 🇮🇹 <strong>FR</strong> 🇫🇷 <strong>DE</strong> 🇩🇪 <strong>ES</strong> 🇪🇸
|
| 421 |
+
</p>
|
| 422 |
+
<p style="color: #64748b; font-size: 0.8em; margin-top: 4px;">
|
| 423 |
+
<strong style="color: #4f46e5;">NER</strong> detects entity mentions •
|
| 424 |
+
<strong style="color: #7c3aed;">NED</strong> links them to Wikidata •
|
| 425 |
+
Entities with ✓ are disambiguated
|
| 426 |
+
</p>
|
| 427 |
+
</div>
|
| 428 |
+
"""
|
| 429 |
+
|
| 430 |
+
EXAMPLES_TEXT = [
|
| 431 |
+
["Elon Musk founded SpaceX in 2002 and serves as CEO of Tesla in Austin, Texas.", "Auto-detect", 0.5],
|
| 432 |
+
["Il presidente Sergio Mattarella ha visitato il Quirinale a Roma con il primo ministro.", "Auto-detect", 0.5],
|
| 433 |
+
["Emmanuel Macron a rencontré Angela Merkel à l'Élysée à Paris pour discuter du Brexit.", "Auto-detect", 0.5],
|
| 434 |
+
["Die Europäische Zentralbank in Frankfurt hat neue geldpolitische Maßnahmen angekündigt.", "Auto-detect", 0.5],
|
| 435 |
+
["Lionel Messi firmó un contrato con el Inter Miami en los Estados Unidos.", "Auto-detect", 0.5],
|
| 436 |
+
]
|
| 437 |
+
|
| 438 |
+
EXAMPLES_URL = [
|
| 439 |
+
["https://en.wikipedia.org/wiki/OpenAI", "Auto-detect", 0.5],
|
| 440 |
+
["https://it.wikipedia.org/wiki/Roma", "Auto-detect", 0.5],
|
| 441 |
+
]
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
with gr.Blocks(
|
| 445 |
+
title="Content Analysis v3 — WordLift",
|
| 446 |
+
css=CUSTOM_CSS,
|
| 447 |
+
theme=gr.themes.Soft(
|
| 448 |
+
primary_hue="indigo",
|
| 449 |
+
secondary_hue="slate",
|
| 450 |
+
neutral_hue="slate",
|
| 451 |
+
font=("Inter", "system-ui", "sans-serif"),
|
| 452 |
+
),
|
| 453 |
+
) as demo:
|
| 454 |
+
|
| 455 |
+
with gr.Tabs():
|
| 456 |
+
# ---- TEXT TAB ----
|
| 457 |
+
with gr.TabItem("📝 Text Analysis", id="text_tab"):
|
| 458 |
+
with gr.Row():
|
| 459 |
+
with gr.Column(scale=3):
|
| 460 |
+
text_input = gr.Textbox(
|
| 461 |
+
label="Input Text",
|
| 462 |
+
placeholder="Enter text to analyze for entities...",
|
| 463 |
+
lines=5,
|
| 464 |
+
max_lines=15,
|
| 465 |
+
)
|
| 466 |
+
with gr.Column(scale=1):
|
| 467 |
+
lang_dropdown = gr.Dropdown(
|
| 468 |
+
choices=["Auto-detect", "en", "it", "fr", "de", "es"],
|
| 469 |
+
value="Auto-detect",
|
| 470 |
+
label="Language",
|
| 471 |
+
)
|
| 472 |
+
confidence_slider = gr.Slider(
|
| 473 |
+
minimum=0.1, maximum=1.0, value=0.5, step=0.05,
|
| 474 |
+
label="Confidence Threshold",
|
| 475 |
+
)
|
| 476 |
+
text_btn = gr.Button("🔍 Analyze", variant="primary", size="lg")
|
| 477 |
+
|
| 478 |
+
stats_output = gr.HTML(label="Summary")
|
| 479 |
+
highlighted_output = gr.HTML(label="Highlighted Text")
|
| 480 |
+
cards_output = gr.HTML(label="Entity Cards")
|
| 481 |
+
|
| 482 |
+
text_btn.click(
|
| 483 |
+
fn=analyze_text_handler,
|
| 484 |
+
inputs=[text_input, lang_dropdown, confidence_slider],
|
| 485 |
+
outputs=[stats_output, highlighted_output, cards_output],
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
gr.Examples(
|
| 489 |
+
examples=EXAMPLES_TEXT,
|
| 490 |
+
inputs=[text_input, lang_dropdown, confidence_slider],
|
| 491 |
+
label="🌍 Try these multilingual examples",
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
# ---- URL TAB ----
|
| 495 |
+
with gr.TabItem("🔗 URL Analysis", id="url_tab"):
|
| 496 |
+
with gr.Row():
|
| 497 |
+
with gr.Column(scale=3):
|
| 498 |
+
url_input = gr.Textbox(
|
| 499 |
+
label="URL",
|
| 500 |
+
placeholder="https://en.wikipedia.org/wiki/...",
|
| 501 |
+
lines=1,
|
| 502 |
+
)
|
| 503 |
+
with gr.Column(scale=1):
|
| 504 |
+
url_lang = gr.Dropdown(
|
| 505 |
+
choices=["Auto-detect", "en", "it", "fr", "de", "es"],
|
| 506 |
+
value="Auto-detect",
|
| 507 |
+
label="Language",
|
| 508 |
+
)
|
| 509 |
+
url_confidence = gr.Slider(
|
| 510 |
+
minimum=0.1, maximum=1.0, value=0.5, step=0.05,
|
| 511 |
+
label="Confidence Threshold",
|
| 512 |
+
)
|
| 513 |
+
url_btn = gr.Button("🔍 Analyze URL", variant="primary", size="lg")
|
| 514 |
+
|
| 515 |
+
url_stats = gr.HTML(label="Summary")
|
| 516 |
+
url_highlighted = gr.HTML(label="Highlighted Text (first 5000 chars)")
|
| 517 |
+
url_cards = gr.HTML(label="Entity Cards")
|
| 518 |
+
url_extracted = gr.Textbox(label="Extracted Text (preview)", lines=5, interactive=False)
|
| 519 |
+
|
| 520 |
+
url_btn.click(
|
| 521 |
+
fn=analyze_url_handler,
|
| 522 |
+
inputs=[url_input, url_lang, url_confidence],
|
| 523 |
+
outputs=[url_stats, url_highlighted, url_cards, url_extracted],
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
gr.Examples(
|
| 527 |
+
examples=EXAMPLES_URL,
|
| 528 |
+
inputs=[url_input, url_lang, url_confidence],
|
| 529 |
+
label="🔗 Try these URLs",
|
| 530 |
+
)
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
if __name__ == "__main__":
|
| 534 |
+
demo.launch(ssr_mode=False)
|
requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# No extra dependencies needed — Gradio (from sdk_version) and requests are pre-installed
|