Spaces:
Sleeping
Sleeping
| """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("&", "&").replace("<", "<").replace(">", ">").replace('"', """) | |
| # --------------------------------------------------------------------------- | |
| # 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 & 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: | |
| 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) | |