CyberFox
fix: force light theme always
1a93c81
Raw
History Blame Contribute Delete
22.5 kB
"""Content Analysis v3 — Hugging Face Space Demo.
Interactive multilingual entity analysis with highlighted text
and entity cards. Supports text input and URL extraction.
Requires a WordLift API key for authentication.
"""
import os
os.environ["GRADIO_SSR_MODE"] = "false"
import gradio as gr
import requests
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
API_BASE = "https://wordlift-lab--content-analysis-v3-web-app.modal.run"
# Entity type → color mapping (for highlighting)
TYPE_COLORS = {
"Person": "#6366f1", # Indigo
"Organization": "#0ea5e9", # Sky blue
"Company": "#0ea5e9",
"City": "#10b981", # Emerald
"Country": "#10b981",
"Place": "#10b981",
"Location": "#10b981",
"Brand": "#f59e0b", # Amber
"Product": "#f59e0b",
"Date": "#8b5cf6", # Violet
"Event": "#ec4899", # Pink
"Movie": "#f43f5e", # Rose
"Book": "#f43f5e",
"Song": "#f43f5e",
"CreativeWork": "#f43f5e",
"MedicalCondition": "#14b8a6", # Teal
"Drug": "#14b8a6",
"SportsTeam": "#0ea5e9",
"EducationalOrganization": "#0ea5e9",
}
DEFAULT_COLOR = "#64748b" # Slate
# ---------------------------------------------------------------------------
# API calls
# ---------------------------------------------------------------------------
def analyze_text_api(text: str, api_key: str, language=None, confidence: float = 0.5) -> dict:
"""Call the Content Analysis v3 text analysis API."""
payload = {"text": text, "confidence": confidence}
if language and language != "auto":
payload["language"] = language
headers = {"Authorization": f"Key {api_key}"}
try:
resp = requests.post(f"{API_BASE}/analyze/text", json=payload, headers=headers, timeout=120)
resp.raise_for_status()
return resp.json()
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 401:
return {"error": "Invalid WordLift API key. Get yours at https://wordlift.io"}
return {"error": str(e), "entities": []}
except requests.exceptions.RequestException as e:
return {"error": str(e), "entities": []}
def analyze_url_api(url: str, api_key: str, language=None, confidence: float = 0.5) -> dict:
"""Call the Content Analysis v3 URL analysis API (extraction happens server-side)."""
payload = {"url": url, "confidence": confidence}
if language and language != "auto":
payload["language"] = language
headers = {"Authorization": f"Key {api_key}"}
try:
resp = requests.post(f"{API_BASE}/analyze/url", json=payload, headers=headers, timeout=120)
resp.raise_for_status()
return resp.json()
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 401:
return {"error": "Invalid WordLift API key. Get yours at https://wordlift.io"}
return {"error": str(e), "entities": []}
except requests.exceptions.RequestException as e:
return {"error": str(e), "entities": []}
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def build_highlighted_html(text: str, entities: list[dict]) -> str:
"""Build HTML with highlighted entity spans and hover tooltips."""
if not entities:
return f'<div class="analyzed-text">{_escape(text)}</div>'
# Sort entities by start position (reverse for safe insertion)
sorted_ents = sorted(entities, key=lambda e: e["start"])
# Build segments
segments = []
last_end = 0
for ent in sorted_ents:
start = ent["start"]
end = ent["end"]
# Skip overlapping entities
if start < last_end:
continue
# Text before entity
if start > last_end:
segments.append(_escape(text[last_end:start]))
# Entity span with tooltip
color = TYPE_COLORS.get(ent["label"], DEFAULT_COLOR)
score = ent.get("score", 0)
entity_id = ent.get("entity_id", "")
entity_label = ent.get("entity_label", "")
entity_desc = ent.get("entity_description", "")
disambig = ent.get("disambiguation_score")
tooltip_parts = [f"Type: {ent['label']}", f"NER Score: {score:.2f}"]
if entity_id:
tooltip_parts.append(f"Entity: {entity_id}")
if entity_label:
tooltip_parts.append(f"Label: {entity_label}")
if disambig is not None:
tooltip_parts.append(f"Disambiguation: {disambig:.2f}")
tooltip = " | ".join(tooltip_parts)
entity_text = _escape(text[start:end])
# NED linked entity gets a special badge
ned_badge = ""
if entity_id:
ned_badge = (
f'<span style="font-size: 0.55em; font-weight: 700; '
f'color: #7c3aed; vertical-align: super; margin-left: 1px;">✓</span>'
)
segments.append(
f'<mark class="entity-highlight" style="background-color: {color}22; '
f'border-bottom: 2px solid {color}; color: inherit; padding: 2px 4px; '
f'border-radius: 3px; cursor: pointer;" title="{_escape(tooltip)}">'
f'{entity_text}'
f'<span class="entity-label" style="font-size: 0.65em; font-weight: 600; '
f'color: {color}; vertical-align: super; margin-left: 2px;">{ent["label"]}</span>'
f'{ned_badge}'
f'</mark>'
)
last_end = end
# Remaining text
if last_end < len(text):
segments.append(_escape(text[last_end:]))
return f'<div class="analyzed-text" style="font-size: 1.05em; line-height: 1.8; padding: 16px;">{"".join(segments)}</div>'
def build_entity_cards_html(entities: list[dict]) -> str:
"""Build HTML entity cards showing details for each detected entity."""
if not entities:
return '<p style="color: #64748b; text-align: center; padding: 2em;">No entities detected.</p>'
# Deduplicate by text
seen = set()
unique = []
for ent in entities:
key = ent.get("text", "")
if key not in seen:
seen.add(key)
unique.append(ent)
cards_html = []
for ent in unique:
color = TYPE_COLORS.get(ent["label"], DEFAULT_COLOR)
score = ent.get("score", 0)
entity_id = ent.get("entity_id")
entity_label = ent.get("entity_label", "")
entity_desc = ent.get("entity_description", "")
disambig = ent.get("disambiguation_score")
# Badge
badge = (
f'<span style="display: inline-block; background: {color}; color: white; '
f'font-size: 0.7em; font-weight: 600; padding: 2px 8px; border-radius: 12px; '
f'letter-spacing: 0.5px; text-transform: uppercase;">{ent["label"]}</span>'
)
# NED status badge
ned_status = ""
if entity_id:
ned_status = (
'<span style="display: inline-block; background: #7c3aed; color: white; '
'font-size: 0.65em; font-weight: 600; padding: 2px 6px; border-radius: 12px; '
'margin-left: 4px;">NED ✓</span>'
)
else:
ned_status = (
'<span style="display: inline-block; background: #e2e8f0; color: #64748b; '
'font-size: 0.65em; font-weight: 600; padding: 2px 6px; border-radius: 12px; '
'margin-left: 4px;">NER only</span>'
)
# QID + DBpedia links
qid_html = ""
if entity_id:
dbpedia_uri = ent.get("dbpedia_uri", "")
dbpedia_link = ""
if dbpedia_uri:
dbpedia_link = (
f' <a href="{dbpedia_uri}" target="_blank" '
f'style="color: #64748b; text-decoration: none; font-size: 0.85em; '
f'font-weight: 500;">📚 DBpedia</a>'
)
qid_html = (
f'<a href="https://www.wikidata.org/wiki/{entity_id}" target="_blank" '
f'style="color: {color}; text-decoration: none; font-size: 0.85em; '
f'font-weight: 500;">🔗 {entity_id}</a>'
f'{dbpedia_link}'
)
# Scores bar
score_bar = _score_bar("NER", score, color)
disambig_bar = ""
if disambig is not None:
disambig_bar = _score_bar("NED", disambig, "#8b5cf6")
# Description
desc_html = ""
if entity_desc:
desc_html = f'<p style="color: #64748b; font-size: 0.85em; margin: 6px 0 0 0; line-height: 1.4;">{_escape(entity_desc)}</p>'
# Entity label (canonical from KB)
label_html = ""
if entity_label and entity_label != ent.get("text", ""):
label_html = f'<p style="color: #64748b; font-size: 0.8em; margin: 2px 0;">aka: {_escape(entity_label)}</p>'
card = f'''
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-left: 3px solid {color};
border-radius: 8px; padding: 14px 16px; margin-bottom: 8px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
<div>
<span style="font-size: 1.05em; font-weight: 600; color: #1e293b;">{_escape(ent.get("text", ""))}</span>
{badge}
{ned_status}
</div>
{qid_html}
</div>
{label_html}
{desc_html}
<div style="margin-top: 8px;">
{score_bar}
{disambig_bar}
</div>
</div>
'''
cards_html.append(card)
return f'<div style="max-height: 500px; overflow-y: auto;">{"".join(cards_html)}</div>'
def build_stats_html(result: dict) -> str:
"""Build summary stats HTML."""
entities = result.get("entities", [])
lang = result.get("language", "—")
time_ms = result.get("processing_time_ms", 0)
version = result.get("pipeline_version", "—")
# Count NER-only vs NED-linked
ned_count = sum(1 for e in entities if e.get("entity_id"))
ner_only_count = len(entities) - ned_count
# Type distribution
type_counts = {}
for ent in entities:
t = ent.get("label", "Unknown")
type_counts[t] = type_counts.get(t, 0) + 1
type_badges = " ".join(
f'<span style="background: {TYPE_COLORS.get(t, DEFAULT_COLOR)}33; color: {TYPE_COLORS.get(t, DEFAULT_COLOR)}; '
f'padding: 3px 10px; border-radius: 12px; font-size: 0.8em; font-weight: 500;">'
f'{t}: {c}</span>'
for t, c in sorted(type_counts.items(), key=lambda x: -x[1])
)
lang_flags = {"en": "🇬🇧", "it": "🇮🇹", "fr": "🇫🇷", "de": "🇩🇪", "es": "🇪🇸"}
flag = lang_flags.get(lang, "🌐")
return f'''
<div style="display: flex; gap: 16px; flex-wrap: wrap; padding: 8px 0;">
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
<div style="font-size: 1.5em; font-weight: 700; color: #4f46e5;">{len(entities)}</div>
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">Entities</div>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
<div style="font-size: 1.5em; font-weight: 700; color: #7c3aed;">{ned_count}</div>
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">NED Linked</div>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
<div style="font-size: 1.5em; font-weight: 700; color: #059669;">{flag} {lang.upper()}</div>
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">Language</div>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
<div style="font-size: 1.5em; font-weight: 700; color: #d97706;">{time_ms:.0f}ms</div>
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">Latency</div>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 16px; flex: 1; min-width: 100px; text-align: center;">
<div style="font-size: 1.1em; font-weight: 600; color: #7c3aed;">{version}</div>
<div style="font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px;">Pipeline</div>
</div>
</div>
<div style="padding: 6px 0;">{type_badges}</div>
'''
def _score_bar(label: str, score: float, color: str) -> str:
"""Render a mini score bar."""
pct = max(0, min(100, score * 100))
return (
f'<div style="display: flex; align-items: center; gap: 8px; margin: 3px 0;">'
f'<span style="font-size: 0.7em; color: #64748b; width: 28px; text-align: right;">{label}</span>'
f'<div style="flex: 1; background: #e2e8f0; border-radius: 4px; height: 6px; overflow: hidden;">'
f'<div style="width: {pct}%; background: {color}; height: 100%; border-radius: 4px;"></div>'
f'</div>'
f'<span style="font-size: 0.75em; color: #475569; width: 40px;">{score:.2f}</span>'
f'</div>'
)
def _escape(s: str) -> str:
"""HTML-escape a string."""
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
# ---------------------------------------------------------------------------
# Gradio handlers
# ---------------------------------------------------------------------------
def analyze_text_handler(text: str, language: str, confidence: float):
"""Handle text analysis."""
api_key = os.environ.get("WL_KEY", "").strip()
if not api_key:
err = "<p style='color:#f43f5e;text-align:center;'>⚠️ WL_KEY secret not configured.</p>"
return err, err, ""
if not text or not text.strip():
msg = "<p style='color:#94a3b8;text-align:center;'>Enter text to analyze.</p>"
return msg, msg, ""
try:
lang = language if language != "Auto-detect" else None
result = analyze_text_api(text, api_key, lang, confidence)
if "error" in result:
err_html = f"<p style='color: #f43f5e;'>API Error: {result['error']}</p>"
return err_html, err_html, ""
entities = result.get("entities", [])
stats = build_stats_html(result)
highlighted = build_highlighted_html(text, entities)
cards = build_entity_cards_html(entities)
return stats, highlighted, cards
except Exception as e:
err_html = f"<p style='color: #f43f5e;'>Error: {str(e)}</p>"
return err_html, err_html, ""
def analyze_url_handler(url: str, language: str, confidence: float):
"""Handle URL analysis."""
api_key = os.environ.get("WL_KEY", "").strip()
if not api_key:
err = "<p style='color:#f43f5e;text-align:center;'>⚠️ WL_KEY secret not configured.</p>"
return err, err, "", ""
if not url or not url.strip():
msg = "<p style='color:#94a3b8;text-align:center;'>Enter a URL to analyze.</p>"
return msg, msg, "", ""
try:
lang = language if language != "Auto-detect" else None
result = analyze_url_api(url, api_key, lang, confidence)
if "error" in result:
err_html = f"<p style='color: #f43f5e;'>API Error: {result['error']}</p>"
return err_html, err_html, "", ""
entities = result.get("entities", [])
text = result.get("extracted_text", result.get("text", ""))
stats = build_stats_html(result)
highlighted = build_highlighted_html(text[:5000], entities)
cards = build_entity_cards_html(entities)
return stats, highlighted, cards, text[:2000]
except Exception as e:
err_html = f"<p style='color: #f43f5e;'>Error: {str(e)}</p>"
return err_html, err_html, "", ""
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
CUSTOM_CSS = """
.gradio-container {
max-width: 1200px !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
}
.analyzed-text {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.entity-highlight:hover {
filter: brightness(0.95);
}
footer { display: none !important; }
"""
DESCRIPTION = """
<div style="text-align: center; padding: 8px 0;">
<p style="color: #475569; font-size: 0.95em; margin: 0;">
Multilingual Named Entity Recognition &amp; Disambiguation powered by
<strong style="color: #4f46e5;">GLiNER</strong> +
<strong style="color: #7c3aed;">BGE-M3</strong> •
Supports <strong>EN</strong> 🇬🇧 <strong>IT</strong> 🇮🇹 <strong>FR</strong> 🇫🇷 <strong>DE</strong> 🇩🇪 <strong>ES</strong> 🇪🇸
</p>
<p style="color: #64748b; font-size: 0.8em; margin-top: 4px;">
<strong style="color: #4f46e5;">NER</strong> detects entity mentions •
<strong style="color: #7c3aed;">NED</strong> links them to Wikidata •
Entities with ✓ are disambiguated
</p>
</div>
"""
EXAMPLES_TEXT = [
["Elon Musk founded SpaceX in 2002 and serves as CEO of Tesla in Austin, Texas.", "Auto-detect", 0.5],
["Il presidente Sergio Mattarella ha visitato il Quirinale a Roma con il primo ministro.", "Auto-detect", 0.5],
["Emmanuel Macron a rencontré Angela Merkel à l'Élysée à Paris pour discuter du Brexit.", "Auto-detect", 0.5],
["Die Europäische Zentralbank in Frankfurt hat neue geldpolitische Maßnahmen angekündigt.", "Auto-detect", 0.5],
["Lionel Messi firmó un contrato con el Inter Miami en los Estados Unidos.", "Auto-detect", 0.5],
]
EXAMPLES_URL = [
["https://en.wikipedia.org/wiki/OpenAI", "Auto-detect", 0.5],
["https://it.wikipedia.org/wiki/Roma", "Auto-detect", 0.5],
]
with gr.Blocks(
title="Content Analysis v3 — WordLift",
css=CUSTOM_CSS,
theme=gr.themes.Soft(
primary_hue="indigo",
secondary_hue="slate",
neutral_hue="slate",
font=("Inter", "system-ui", "sans-serif"),
),
) as demo:
# Force light theme regardless of user's system preference
demo.load(None, js="() => { document.querySelector('body').classList.remove('dark'); document.documentElement.style.colorScheme = 'light'; }")
with gr.Tabs():
# ---- TEXT TAB ----
with gr.TabItem("📝 Text Analysis", id="text_tab"):
with gr.Row():
with gr.Column(scale=3):
text_input = gr.Textbox(
label="Input Text",
placeholder="Enter text to analyze for entities...",
lines=5,
max_lines=15,
)
with gr.Column(scale=1):
lang_dropdown = gr.Dropdown(
choices=["Auto-detect", "en", "it", "fr", "de", "es"],
value="Auto-detect",
label="Language",
)
confidence_slider = gr.Slider(
minimum=0.1, maximum=1.0, value=0.5, step=0.05,
label="Confidence Threshold",
)
text_btn = gr.Button("🔍 Analyze", variant="primary", size="lg")
stats_output = gr.HTML(label="Summary")
highlighted_output = gr.HTML(label="Highlighted Text")
cards_output = gr.HTML(label="Entity Cards")
text_btn.click(
fn=analyze_text_handler,
inputs=[text_input, lang_dropdown, confidence_slider],
outputs=[stats_output, highlighted_output, cards_output],
)
gr.Examples(
examples=EXAMPLES_TEXT,
inputs=[text_input, lang_dropdown, confidence_slider],
label="🌍 Try these multilingual examples",
)
# ---- URL TAB ----
with gr.TabItem("🔗 URL Analysis", id="url_tab"):
with gr.Row():
with gr.Column(scale=3):
url_input = gr.Textbox(
label="URL",
placeholder="https://en.wikipedia.org/wiki/...",
lines=1,
)
with gr.Column(scale=1):
url_lang = gr.Dropdown(
choices=["Auto-detect", "en", "it", "fr", "de", "es"],
value="Auto-detect",
label="Language",
)
url_confidence = gr.Slider(
minimum=0.1, maximum=1.0, value=0.5, step=0.05,
label="Confidence Threshold",
)
url_btn = gr.Button("🔍 Analyze URL", variant="primary", size="lg")
url_stats = gr.HTML(label="Summary")
url_highlighted = gr.HTML(label="Highlighted Text (first 5000 chars)")
url_cards = gr.HTML(label="Entity Cards")
url_extracted = gr.Textbox(label="Extracted Text (preview)", lines=5, interactive=False)
url_btn.click(
fn=analyze_url_handler,
inputs=[url_input, url_lang, url_confidence],
outputs=[url_stats, url_highlighted, url_cards, url_extracted],
)
gr.Examples(
examples=EXAMPLES_URL,
inputs=[url_input, url_lang, url_confidence],
label="🔗 Try these URLs",
)
if __name__ == "__main__":
demo.launch(ssr_mode=False)