""" Universal Dependencies Parser & Visualizer Gradio Web Application following a calm, pedagogical design. Tabs: Tabs are ordered About, Parse, Compare, Annotate & Export, Treebank Guide; About opens by default. Annotate & Export also defines the public API endpoints (see API_MD). """ import html import inspect import json import os import tempfile from typing import List, Optional import gradio as gr import pandas as pd ENABLE_SCREENCAST = os.getenv("ENABLE_SCREENCAST", "").strip().lower() in {"1", "true", "yes", "on"} ScreenRecorder = None if ENABLE_SCREENCAST: try: from gradio_screenrecorder import ScreenRecorder except ImportError: ScreenRecorder = None ENABLE_SCREENCAST = False from core.udpipe_client import UDPipeClient from core.conllu_utils import conllu_to_table, compare_trees, extract_tree_signature, BoundedParseCache from core.treebanks_data import TREEBANK_CATALOG, AVAILABLE_MODELS from core.annotate import annotate_text, model_ids from core.tree_export import TreeExportError, export_tree_images from visualizer.template import render_conllu_visualizer_html, render_side_by_side_visualizers_html # Initialize client client = UDPipeClient() # Load curated examples EXAMPLES_FILE = os.path.join(os.path.dirname(__file__), "examples.json") with open(EXAMPLES_FILE, "r", encoding="utf-8") as f: CURATED_EXAMPLES = json.load(f) # Stylesheet for calm academic styling with Source Sans Pro CUSTOM_HEAD = """ """ CUSTOM_CSS = """ /* Calm academic aesthetic with Source Sans Pro */ html, body, .gradio-container, button, input, select, textarea, .prose { font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important; } .gradio-container { width: 100% !important; max-width: none !important; padding: 0 !important; margin: 0 !important; box-sizing: border-box; } .gradio-container main.fillable { width: 100% !important; max-width: none !important; padding: 0 !important; margin: 0 !important; box-sizing: border-box; } .wrap { padding-left: 16px !important; padding-right: 16px !important; box-sizing: border-box !important; } /* Gradio uses input for one line and textarea for multiline textboxes. Keep the sentence prominent in either form: 24px is intentional. */ .sentence-input input, .sentence-input textarea { font-size: 24px !important; line-height: 1.5 !important; } .gradio-container { background-color: var(--background-fill-primary) !important; } .overview-card { background-color: var(--background-fill-secondary); border: 1px solid var(--border-color-primary); border-radius: 6px; padding: 16px; } .diff-alert { padding: 10px 14px; border-radius: 6px; margin-bottom: 10px; font-size: 13px; } .diff-alert-same { background-color: var(--color-accent-soft, #ecfdf5); border: 1px solid #a7f3d0; color: #065f46; } .diff-alert-diff { background-color: #fef2f2; border: 1px solid #fecaca; color: #991b1b; } .dark .diff-alert-same { background-color: rgba(6, 95, 70, 0.25); border: 1px solid #065f46; color: #6ee7b7; } .dark .diff-alert-diff { background-color: rgba(153, 27, 27, 0.25); border: 1px solid #991b1b; color: #fca5a5; } /* Promotional feature grid ("Kacheln") */ .promo-hero { background: linear-gradient(135deg, #eff6ff 0%, #f5f3ff 50%, #fdf2f8 100%); border: 1px solid #e0e7ff; border-radius: 12px; padding: 28px 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(99, 102, 241, 0.05); } .promo-hero-badge { display: inline-flex; align-items: center; gap: 6px; background: #ffffff; border: 1px solid #c7d2fe; color: #4338ca; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; padding: 4px 12px; border-radius: 9999px; margin-bottom: 12px; } .promo-hero-title { font-size: 30px !important; font-weight: 800 !important; line-height: 1.25 !important; color: #1e1b4b !important; margin: 0 0 10px 0 !important; } .promo-hero-desc { font-size: 16px; line-height: 1.6; color: #1f2937; max-width: 820px; margin-bottom: 0; } .promo-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 18px; margin-bottom: 28px; } .promo-card { display: flex !important; flex-direction: column !important; justify-content: space-between !important; text-decoration: none !important; color: inherit !important; border-radius: 10px; padding: 22px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); transition: background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; cursor: pointer; } .promo-card:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } /* Individual card color accents */ .promo-card-blue { background: linear-gradient(180deg, #ffffff 0%, #f0f7ff 100%); border: 1px solid #bfdbfe; border-top: 4px solid #2563eb; } .promo-card-blue:hover { background: linear-gradient(180deg, #ffffff 0%, #e0effe 100%); border-color: #93c5fd; border-top-color: #1d4ed8; } .promo-card-amber { background: linear-gradient(180deg, #ffffff 0%, #fffbeb 100%); border: 1px solid #fde68a; border-top: 4px solid #d97706; } .promo-card-amber:hover { background: linear-gradient(180deg, #ffffff 0%, #fef3c7 100%); border-color: #fcd34d; border-top-color: #b45309; } .promo-card-purple { background: linear-gradient(180deg, #ffffff 0%, #faf5ff 100%); border: 1px solid #e9d5ff; border-top: 4px solid #7c3aed; } .promo-card-purple:hover { background: linear-gradient(180deg, #ffffff 0%, #f3e8ff 100%); border-color: #d8b4fe; border-top-color: #6d28d9; } .promo-card-teal { background: linear-gradient(180deg, #ffffff 0%, #f0fdfa 100%); border: 1px solid #99f6e4; border-top: 4px solid #0d9488; } .promo-card-teal:hover { background: linear-gradient(180deg, #ffffff 0%, #ccfbf1 100%); border-color: #5eead4; border-top-color: #0f766e; } .promo-card-rose { background: linear-gradient(180deg, #ffffff 0%, #fff1f2 100%); border: 1px solid #fecdd3; border-top: 4px solid #e11d48; } .promo-card-rose:hover { background: linear-gradient(180deg, #ffffff 0%, #ffe4e6 100%); border-color: #fda4af; border-top-color: #be123c; } .promo-card-emerald { background: linear-gradient(180deg, #ffffff 0%, #f0fdf4 100%); border: 1px solid #a7f3d0; border-top: 4px solid #059669; } .promo-card-emerald:hover { background: linear-gradient(180deg, #ffffff 0%, #d1fae5 100%); border-color: #6ee7b7; border-top-color: #047857; } .promo-tag { display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; padding: 4px 10px; border-radius: 6px; margin-bottom: 12px; width: fit-content; } .pt-blue { background: #dbeafe; color: #1e40af; border: 1px solid #93c5fd; } .pt-amber { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; } .pt-purple { background: #ede9fe; color: #5b21b6; border: 1px solid #d8b4fe; } .pt-teal { background: #ccfbf1; color: #115e59; border: 1px solid #5eead4; } .pt-rose { background: #ffe4e6; color: #9f1239; border: 1px solid #fda4af; } .pt-emerald { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; } .promo-title { font-size: 19px; font-weight: 700; margin: 0 0 6px 0; display: flex; align-items: center; gap: 8px; } .promo-card-blue .promo-title { color: #1e3a8a; } .promo-card-amber .promo-title { color: #78350f; } .promo-card-purple .promo-title { color: #4c1d95; } .promo-card-teal .promo-title { color: #134e4a; } .promo-card-rose .promo-title { color: #881337; } .promo-card-emerald .promo-title { color: #064e3b; } .promo-subtitle-hint { display: inline-block; font-size: 12px; font-weight: 600; color: #b45309; margin-bottom: 8px; } .promo-desc { font-size: 14.5px; line-height: 1.6; color: #111827; margin-bottom: 18px; flex-grow: 1; } .promo-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; color: #ffffff !important; text-decoration: none !important; transition: all 0.15s ease; width: fit-content; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .pb-blue { background: #2563eb; } .pb-blue:hover { background: #1d4ed8; } .pb-amber { background: #d97706; } .pb-amber:hover { background: #b45309; } .pb-purple { background: #7c3aed; } .pb-purple:hover { background: #6d28d9; } .pb-teal { background: #0d9488; } .pb-teal:hover { background: #0f766e; } .pb-rose { background: #e11d48; } .pb-rose:hover { background: #be123c; } .pb-emerald { background: #059669; } .pb-emerald:hover { background: #047857; } .promo-workflow-banner { background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 10px; padding: 20px 24px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); text-align: center; } .workflow-steps { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px 14px; margin-bottom: 12px; } .workflow-step { background: #ffffff; border: 1px solid #cbd5e1; border-radius: 6px; padding: 6px 14px; font-size: 13.5px; font-weight: 700; color: #1e3a8a; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .workflow-arrow { color: #64748b; font-weight: bold; font-size: 14px; } .workflow-note { font-size: 14px; color: #475569; font-style: italic; margin: 0; } /* Dark mode enhancements */ .dark .promo-hero { background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 100%); border-color: #312e81; } .dark .promo-hero-badge { background: #1e1b4b; border-color: #4338ca; color: #c7d2fe; } .dark .promo-hero-title { color: #e0e7ff !important; } .dark .promo-hero-desc { color: #cbd5e1; } .dark .promo-card-blue { background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: #1e3a8a; } .dark .promo-card-blue:hover { background: linear-gradient(180deg, #24344d 0%, #131d2e 100%); border-color: #3b82f6; } .dark .promo-card-amber { background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: #78350f; } .dark .promo-card-amber:hover { background: linear-gradient(180deg, #2e261f 0%, #1a1612 100%); border-color: #f59e0b; } .dark .promo-card-purple { background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: #4c1d95; } .dark .promo-card-purple:hover { background: linear-gradient(180deg, #29203d 0%, #171224 100%); border-color: #a855f7; } .dark .promo-card-teal { background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: #134e4a; } .dark .promo-card-teal:hover { background: linear-gradient(180deg, #182f2f 0%, #0d1e1e 100%); border-color: #14b8a6; } .dark .promo-card-rose { background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: #881337; } .dark .promo-card-rose:hover { background: linear-gradient(180deg, #331922 0%, #1e0d13 100%); border-color: #f43f5e; } .dark .promo-card-emerald { background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); border-color: #064e3b; } .dark .promo-card-emerald:hover { background: linear-gradient(180deg, #162e24 0%, #0d1e17 100%); border-color: #10b981; } .dark .promo-title { color: #f8fafc !important; } .dark .promo-subtitle-hint { color: #fde68a !important; } .dark .promo-desc { color: #e2e8f0 !important; } .dark .promo-workflow-banner { background: #1e293b; border-color: #334155; } .dark .workflow-step { background: #0f172a; border-color: #334155; color: #93c5fd; } .dark .workflow-arrow { color: #94a3b8; } .dark .workflow-note { color: #cbd5e1; } """ OVERVIEW_MD = """
✨ Universal Dependencies & UDPipe

Universal Dependencies Parser & Visualizer

Explore grammatical structure in English and German sentences using Universal Dependencies. This teaching tool combines neural dependency parsing, interactive tree visualizations, grammatical documentation, and treebank examples.

Inspect the analyses, compare different interpretations, and evaluate the grammatical decisions made by automatic parsers.

DEPENDENCY PARSING
⚡ Parse English & German
Enter a sentence or select an example. UDPipe analyzes the words and displays their part-of-speech tags, lemmas, morphological features, and dependency relations. Inspect the resulting tree to see how words are connected.
Open the Parse tab →
INTERACTIVE EXPLORATION
🎯 Explore Heads & Dependencies
Try the "Head Hunt" interaction
Hover over a word to highlight its syntactic head. Hover over a POS tag or dependency arc to focus on relevant grammatical relationships while the rest of the tree fades. Follow dependency chains to explore sentence structure.
Try in the Parse tab →
GRAMMATICAL REFERENCE
📖 Look Up UD Categories
Look up unfamiliar part-of-speech tags, dependency relations, and morphological features. The contextual Help panel links categories in the analysis to their definitions and examples in the official Universal Dependencies documentation.
Open Help below the tree →
CORPUS EXPLORATION
🔍 Examine Treebank Examples
Click a word in the dependency tree to search for examples in an annotated corpus using Grew. Examine how words and constructions are analyzed in a treebank, and compare those examples with the parser’s output.
Click a word to search in Grew →
MODEL COMPARISON
⚖️ Compare Parser Analyses
Analyze the same sentence with two different treebank-based models. A side-by-side view highlights differences in part-of-speech tags and dependency attachments. Discuss whether disagreements reflect ambiguity, annotation conventions, or possible parser errors.
Open the Compare tab →
DATA & EXPORT
💾 Export & Process Analyses
Download dependency trees as SVG or PNG graphics, or access the underlying CoNLL-U representation. The batch interface and API support processing multiple sentences and using the analyses in other applications.
Open Annotate & Export →
Parse → Inspect → Consult UD → Explore examples → Compare → Export

Remember: A dependency tree is an analysis of a sentence, not necessarily the only possible analysis.

""" CONFORMANCE_DOC_MD = """ **Parser and model identification**: Parser names in this application show UDPipe and the model ID used in the raw CoNLL-U output, including the training treebank and UD release (for example, `UDPipe · english-ewt-ud-2.17-251125`). **Training size** is the number of syntactic words in the training split, not the size of the whole corpus. Smaller training sets provide fewer examples and can increase errors on uncommon constructions; size alone does not predict correctness on your sentence. **Quality and conformance** concern how consistently annotations follow the universal and language-specific UD guidelines. Automated validation checks format and many constraints, but cannot verify every linguistic decision. Manual review and conversion history are useful evidence, not a compliance score. These sources do not establish a comparable conformance ranking. In **Compare**, inspect disagreements against the sentence and the [UD syntax guidelines](https://universaldependencies.org/u/overview/syntax.html) ([German](https://universaldependencies.org/de/), [English](https://universaldependencies.org/en/)). The table describes annotation methods, rather than assigning quality labels. Sizes refer to **UD 2.17**, the training release of the app's configured models. Treebank names link to their documentation; training counts link to the release-specific statistics. | Treebank | Training words | Annotation quality / conformance evidence | Why included | | :--- | ---: | :--- | :--- | """ + "\n".join( f"| [{row['Language']}-{row['Treebank']}]({row['SourceURL']}) " f"| [{row['TrainingWords']}]({row['StatsURL']}) " f"| {row['Conformance']} | {row['BestFor']} |" for row in TREEBANK_CATALOG ) + """ [UD validation: what is checked](https://universaldependencies.org/contributing/validation.html) · [UDPipe model training and evaluation](https://ufal.mff.cuni.cz/udpipe/2/models) """ def run_single_or_compare( lang: str, sentence: str, primary_model_name: str, compare_mode: bool, secondary_model_name: str, include_morphology: bool = False, cache: Optional[BoundedParseCache] = None ): """ Executes parsing for one sentence. If compare_mode is active, queries both models and diffs POS tags + dependencies. """ sentence = sentence.strip() if not sentence: return ( "
Please enter a sentence to parse.
", pd.DataFrame(), "", "", gr.update(visible=False), pd.DataFrame(), ) # Restrict strictly to one sentence (first line / sentence split) if "\n" in sentence: sentence = sentence.split("\n")[0].strip() models_dict = AVAILABLE_MODELS.get(lang, AVAILABLE_MODELS["English"]) model_id_a = models_dict.get(primary_model_name, list(models_dict.values())[0]) strip_morphology = not include_morphology res_a = client.parse_sentence(sentence, model_id_a, strip_morphology=strip_morphology, cache=cache) error_a = res_a.get("error") if not error_a and not extract_tree_signature(res_a.get("conllu", "")): error_a = "The primary model returned no usable parse tokens." if error_a: err_msg = f"
Error: {html.escape(str(error_a))}
" return err_msg, pd.DataFrame(), "", "", gr.update(visible=False), pd.DataFrame() conllu_a = res_a["conllu"] table_a = conllu_to_table(conllu_a) if not compare_mode: # Single Tree Mode vis_html = render_conllu_visualizer_html(conllu_a, vis_id="vis-primary", title=primary_model_name, show_help=True, model_id=model_id_a) status_html = "" return ( vis_html, table_a, conllu_a, status_html, gr.update(visible=False), pd.DataFrame(), ) elif primary_model_name == secondary_model_name: # Same model selected twice in comparison mode vis_html = render_conllu_visualizer_html(conllu_a, vis_id="vis-primary", title=primary_model_name, model_id=model_id_a) m_name_esc = html.escape(primary_model_name) status_html = ( f"
✓ Same UDPipe Parser Selected: " f"Both parsers are set to {m_name_esc} ({len(table_a)} tokens). " f"Select a different second parser to inspect differences.
" ) return ( vis_html, table_a, conllu_a, status_html, gr.update(visible=False), pd.DataFrame(), ) else: # Comparison Mode: Query Secondary Model model_id_b = models_dict.get(secondary_model_name, list(models_dict.values())[0]) res_b = client.parse_sentence(sentence, model_id_b, strip_morphology=strip_morphology, cache=cache) conllu_b = res_b.get("conllu", "") error_b = res_b.get("error") if not error_b and not extract_tree_signature(conllu_b): error_b = "The secondary model returned no usable parse tokens." if error_b: vis_html = render_conllu_visualizer_html( conllu_a, vis_id="vis-primary", title=primary_model_name, model_id=model_id_a ) status_html = ( "
Comparison unavailable: " f"{html.escape(secondary_model_name)}: {html.escape(str(error_b))}
" ) return vis_html, table_a, conllu_a, status_html, gr.update(visible=False), pd.DataFrame() # Diff trees diff_info = compare_trees(conllu_a, primary_model_name, conllu_b, secondary_model_name) vis_html = render_side_by_side_visualizers_html( conllu_a, primary_model_name, conllu_b, secondary_model_name, model_id_a=model_id_a, model_id_b=model_id_b ) raw_conllu_combined = f"# === {primary_model_name} ===\n{conllu_a}\n\n# === {secondary_model_name} ===\n{conllu_b}" if diff_info["identical"]: status_html = f"
✓ Identical Trees: Both UDPipe parsers produced matching POS tags and syntactic dependency arcs.
" diff_visible = False else: diff_text = html.escape(diff_info["diff_summary"]) model_a_escaped = html.escape(primary_model_name) model_b_escaped = html.escape(secondary_model_name) diff_text = diff_text.replace(f"**{model_a_escaped}**", f"{model_a_escaped}").replace(f"**{model_b_escaped}**", f"{model_b_escaped}") status_html = f"
⚡ Discrepancies Found: {diff_text}
" diff_visible = True return ( vis_html, table_a, raw_conllu_combined, status_html, gr.update(visible=diff_visible), diff_info["diff_table"], ) def _describe_example(example): description = example["phenomenon"] if example.get("source_url"): description += ( f"\n\nSource: [{example['source_corpus']} {example['source_version']}, " f"sentence {example['sent_id']}]({example['source_url']}) " f"· [{example['source_license']}](https://creativecommons.org/licenses/by-sa/3.0/)" ) return description def _get_example_choices(lang): return [(ex["text"], i) for i, ex in enumerate(CURATED_EXAMPLES) if ex["language"] == lang] def _get_first_example_index_for_lang(lang: str) -> int: for i, ex in enumerate(CURATED_EXAMPLES): if ex["language"] == lang: return i return 0 def resolve_model_label(lang: str, query: str) -> Optional[str]: """Resolves a model query (ID, label, or substring) to a display label.""" if not query: return None models = list(AVAILABLE_MODELS.get(lang, {}).keys()) for m in models: if m.lower() == query.lower(): return m for m in models: mid = AVAILABLE_MODELS[lang][m] if mid.lower() == query.lower(): return m for m in models: mid = AVAILABLE_MODELS[lang][m] if query.lower() in mid.lower() or query.lower() in m.lower(): return m return None def build_parser(compare=False): """Build independent, sentence-first controls for each parsing mode.""" sentence = gr.Textbox(label="Sentence", value="They buy and sell books.", placeholder="Enter an English or German sentence...", lines=1, max_lines=1, elem_classes=["sentence-input"]) with gr.Row(): language = gr.Dropdown(label="Language", choices=["English", "German"], value="English", scale=1) primary = gr.Dropdown(label="UDPipe parser" if not compare else "First UDPipe parser", choices=list(AVAILABLE_MODELS["English"]), value=list(AVAILABLE_MODELS["English"])[0], scale=2) secondary = gr.Dropdown(label="Second UDPipe parser", choices=list(AVAILABLE_MODELS["English"]), value=list(AVAILABLE_MODELS["English"])[1], visible=compare, scale=2) with gr.Column(scale=1): with gr.Row(): submit = gr.Button("Compare" if compare else "Parse", variant="primary", scale=3) share_btn = gr.Button("🔗 Share", size="sm", scale=2, min_width=75) include_morphology = gr.Checkbox(label="Include morphology (FEATS)", value=False) with gr.Accordion("Examples", open=False): selected_example = gr.State(0) with gr.Row(): german_examples = gr.Dropdown(label="German Examples", choices=_get_example_choices("German"), value=None, scale=1, min_width=240, allow_custom_value=False) english_examples = gr.Dropdown(label="English Examples", choices=_get_example_choices("English"), value=0, scale=1, min_width=240, allow_custom_value=False) phenomenon = gr.Markdown(_describe_example(CURATED_EXAMPLES[0])) status = gr.HTML(visible=compare) visualization = gr.HTML() with gr.Group(visible=False) as differences: gr.Markdown("### Differences") diff_table = gr.DataFrame(interactive=False) with gr.Accordion("Annotations", open=False): table = gr.DataFrame(headers=["ID", "Form", "Lemma", "UPOS", "FEATS", "Head", "Deprel"], interactive=False) with gr.Accordion("CoNLL-U", open=False): raw = gr.Code(label="CoNLL-U", language="markdown", interactive=False) def on_language_input(new_lang): models = list(AVAILABLE_MODELS[new_lang]) return ( gr.update(choices=models, value=models[0]), gr.update(choices=models, value=models[min(1, len(models)-1)]), ) def on_sentence_change(): return ( None, gr.update(value=None), gr.update(value=None), "", ) def on_example_input(new_ex_idx, current_lang, current_primary, current_secondary, morph, cache): if new_ex_idx is None: return tuple(gr.update() for _ in range(14)) ex = CURATED_EXAMPLES[int(new_ex_idx)] ex_lang = ex["language"] if ex_lang != current_lang: models = list(AVAILABLE_MODELS[ex_lang]) primary_name = models[0] secondary_name = models[min(1, len(models)-1)] model_updates = ( gr.update(choices=models, value=primary_name), gr.update(choices=models, value=secondary_name), ) else: primary_name = current_primary secondary_name = current_secondary model_updates = (gr.update(), gr.update()) vis_html, table_df, raw_conllu, status_html, diff_group, diff_df = run_single_or_compare( lang=ex_lang, sentence=ex["text"], primary_model_name=primary_name, compare_mode=compare, secondary_model_name=secondary_name, include_morphology=morph, cache=cache, ) return ( ex_lang, *model_updates, _describe_example(ex), ex["text"], int(new_ex_idx), gr.update(value=int(new_ex_idx) if ex_lang == "German" else None), gr.update(value=int(new_ex_idx) if ex_lang == "English" else None), vis_html, table_df, raw_conllu, status_html, diff_group, diff_df, ) language.input( on_language_input, [language], [primary, secondary], api_name=False, ) sentence.input( on_sentence_change, None, [selected_example, german_examples, english_examples, phenomenon], api_name=False, ) # An instance, not the class: Gradio deep-copies it per session, and a # callable default would register a public load endpoint. # An instance, not the class: Gradio deep-copies State per session, and a # callable default would publish a load endpoint in the API. session_cache = gr.State(BoundedParseCache()) for example_dropdown in (german_examples, english_examples): example_dropdown.input( on_example_input, [example_dropdown, language, primary, secondary, include_morphology, session_cache], [language, primary, secondary, phenomenon, sentence, selected_example, german_examples, english_examples, visualization, table, raw, status, differences, diff_table], api_name=False, ) inputs = [language, sentence, primary, gr.State(compare), secondary, include_morphology, session_cache] outputs = [visualization, table, raw, status, differences, diff_table] submit.click(run_single_or_compare, inputs, outputs, api_name=False) sentence.submit(run_single_or_compare, inputs, outputs, api_name=False) share_btn.click( fn=None, inputs=[], outputs=[], js="""() => { if (window.updateShareUrl) window.updateShareUrl(); const url = window.location.href; navigator.clipboard.writeText(url).then(() => { const btn = document.activeElement; if (btn) { const oldText = btn.textContent; btn.textContent = '✓ Copied!'; setTimeout(() => { btn.textContent = oldText; }, 2000); } }).catch(() => { prompt('Copy shareable link:', url); }); return []; }""", api_name=False, ) return { "sentence": sentence, "language": language, "primary": primary, "secondary": secondary, "include_morphology": include_morphology, "session_cache": session_cache, "visualization": visualization, "table": table, "raw": raw, "status": status, "differences": differences, "diff_table": diff_table, } EXPORT_FORMATS = ["CoNLL-U", "SVG", "PNG"] API_MD = """ Each button below is also an API endpoint. From Python: ```python from gradio_client import Client client = Client("simon-clmtd/universal-dependencies-visualizer") conllu, files = client.predict( "Bestimmt hat sie die Lösung bestimmt.", "german-hdt-ud-2.17-251125", False, ["CoNLL-U", "SVG", "PNG"], api_name="/annotate") files = client.predict(conllu, ["SVG", "PNG"], api_name="/render") client.predict(api_name="/models") ``` - `/annotate(text, model, include_morphology, formats)`: parses each non-empty line (at most 50) and returns the CoNLL-U text and the requested files. - `/render(conllu, formats)`: draws your own or corrected CoNLL-U as SVG and PNG, exactly like the tree download buttons. - `/models()`: lists the model IDs. Images take a few seconds per request because a browser draws each tree. """ def _write_exports(conllu: str, formats: List[str], stem: str = "ud-analysis") -> List[str]: """Writes the requested CoNLL-U/SVG/PNG files to a fresh temporary directory.""" folder = tempfile.mkdtemp(prefix="ud-export-") paths = [] if "CoNLL-U" in formats: path = os.path.join(folder, stem + ".conllu") with open(path, "w", encoding="utf-8") as handle: handle.write(conllu) paths.append(path) image_formats = [name for name in ("SVG", "PNG") if name in formats] if image_formats: try: images = export_tree_images(conllu) except TreeExportError as error: raise gr.Error(str(error)) for name in image_formats: path = os.path.join(folder, f"{stem}.{name.lower()}") with open(path, "wb") as handle: handle.write(images[name.lower()]) paths.append(path) return paths def annotate_and_export(text: str, model: str, include_morphology: bool, formats: List[str]): """Parses each line of text with one UDPipe model and exports the analysis.""" try: result = annotate_text(text, model, include_morphology=include_morphology, client=client) except (ValueError, RuntimeError) as error: raise gr.Error(str(error)) return result["conllu"], _write_exports(result["conllu"], formats or []) def render_conllu(conllu: str, formats: List[str]): """Draws supplied CoNLL-U as SVG/PNG (and returns it as a file if requested).""" if not conllu or not conllu.strip(): raise gr.Error("Paste CoNLL-U to render.") return _write_exports(conllu if conllu.endswith("\n") else conllu + "\n", formats or []) def list_models() -> List[str]: """Returns the selectable UDPipe model IDs.""" return model_ids() def build_exporter(): """Batch annotation and file export; its events are the public API.""" gr.Markdown( "Annotate one sentence per line, or paste CoNLL-U (for example a corrected " "analysis) and render it. Download the CoNLL-U, SVG, and PNG files below." ) with gr.Row(): with gr.Column(): text = gr.Textbox(label="Sentences (one per line)", lines=6, value="Bestimmt hat sie die Lösung bestimmt.") with gr.Row(): model = gr.Dropdown(label="UDPipe model", choices=model_ids(), value="german-hdt-ud-2.17-251125", scale=2) include_morphology = gr.Checkbox(label="Include morphology (FEATS)", value=False, scale=1) formats = gr.CheckboxGroup(EXPORT_FORMATS, value=EXPORT_FORMATS, label="Files") annotate = gr.Button("Annotate", variant="primary") with gr.Column(): conllu = gr.Code(label="CoNLL-U (editable)", language="markdown", interactive=True, lines=12) render_formats = gr.CheckboxGroup(EXPORT_FORMATS, value=["SVG", "PNG"], label="Files") render = gr.Button("Render CoNLL-U") files = gr.File(label="Downloads", file_count="multiple", interactive=False) with gr.Accordion("Use as an API", open=False): gr.Markdown(API_MD) annotate.click(annotate_and_export, [text, model, include_morphology, formats], [conllu, files], api_name="annotate", concurrency_limit=2) render.click(render_conllu, [conllu, render_formats], [files], api_name="render", concurrency_limit=2) gr.api(list_models, api_name="models") _blocks_init_params = inspect.signature(gr.Blocks.__init__).parameters _blocks_kwargs = {"title": "Universal Dependencies Visualizer"} _launch_kwargs = {} if "css" in _blocks_init_params: _blocks_kwargs.update(theme=gr.themes.Base(), css=CUSTOM_CSS, head=CUSTOM_HEAD) else: _launch_kwargs.update(theme=gr.themes.Base(), css=CUSTOM_CSS, head=CUSTOM_HEAD) with gr.Blocks(**_blocks_kwargs) as demo: if ENABLE_SCREENCAST and ScreenRecorder is not None: with gr.Accordion("Record screencast (experimental)", open=False, elem_id="screencast-panel"): gr.Markdown( "Record your exploration with microphone narration. Choose this browser " "tab or a window in the screen-sharing dialog, then explore Parse or Compare. " "Stop recording, then click Download WebM. The video is saved directly " "from your browser." ) screen_recorder = ScreenRecorder( label="Screencast", audio_enabled=True, webcam_overlay=False, recording_format="webm", max_duration=600, ) download_recording = gr.Button("Download WebM", size="sm") # 0.0.1 does not bridge completion events to Gradio or upload FileData. # Save its browser-local preview instead of relying on that broken path. download_recording.click( fn=None, inputs=[], outputs=[], api_name=False, js="""() => { const video = Array.from(document.querySelectorAll('#screencast-panel .recording-preview video')) .find(video => video.src.startsWith('blob:')); if (!video) { alert('Stop a recording before downloading it.'); return []; } const link = document.createElement('a'); link.href = video.src; link.download = 'ud-exploration.webm'; document.body.appendChild(link); link.click(); link.remove(); return []; }""" ) with gr.Tabs(selected="tab_overview") as tabs: with gr.Tab("About", id="tab_overview"): gr.HTML(OVERVIEW_MD) with gr.Tab("Parse", id="tab_parser"): parse_ctrls = build_parser() with gr.Tab("Compare", id="tab_compare"): compare_ctrls = build_parser(compare=True) with gr.Tab("Annotate & Export", id="tab_export"): build_exporter() with gr.Tab("Treebank Guide", id="tab_guide"): with gr.Accordion("Training size, quality and conformance", open=False): # Remove duplicate heading since accordion provides the label doc_lines = CONFORMANCE_DOC_MD.strip().splitlines() if doc_lines and doc_lines[0].startswith("### "): conformance_content = "\n".join(doc_lines[1:]).strip() else: conformance_content = CONFORMANCE_DOC_MD gr.Markdown(conformance_content) with gr.Accordion("Explore corpora with Grew", open=False): gr.Markdown( "Go beyond a single parse: search annotated corpus sentences, " "look for recurring patterns, and find counterexamples to your expectations. " "Use Grew's built-in dependency-relation search examples to get started. " "Inspect several matches and explain what their trees have in common.\n\n" "| Treebank on Grew | Try exploring |\n" "| --- | --- |\n" + "\n".join( f"| [{row['Language']}-{row['Treebank']}]({row['GrewURL']}) " f"| {row['ExplorePrompt']} |" for row in TREEBANK_CATALOG ) ) with gr.Accordion("Full treebank catalog", open=False): catalog_rows = [ [ row["Language"], row["Treebank"], row["Words"], row["TrainingWords"], row["Sentences"], row["Genres"], row["BestFor"], ] for row in TREEBANK_CATALOG ] catalog_df = gr.DataFrame( value=catalog_rows, headers=["Language", "Treebank", "Total words (UD 2.17)", "Training words", "Sentences", "Text sources", "Role in this app"], interactive=False, ) def load_from_request(request: gr.Request): params = request.query_params if request else {} if not params: return tuple(gr.update() for _ in range(22)) tab_param = params.get("tab", "").strip().lower() sentence_param = params.get("sentence", "").strip() model_param = params.get("model", "").strip() model2_param = params.get("model2", "").strip() lang_param = params.get("lang", "").strip() morph = params.get("morph", "").strip().lower() in {"1", "true", "yes"} if tab_param in {"compare", "tab_compare"} or model2_param: target_tab = "tab_compare" elif tab_param in {"parse", "tab_parser"} or sentence_param: target_tab = "tab_parser" elif tab_param in {"export", "annotate", "tab_export"}: target_tab = "tab_export" elif tab_param in {"guide", "treebank", "tab_guide"}: target_tab = "tab_guide" elif tab_param in {"about", "overview", "tab_overview"}: target_tab = "tab_overview" else: target_tab = "tab_overview" if lang_param and lang_param.capitalize() in AVAILABLE_MODELS: lang = lang_param.capitalize() elif "german" in model_param.lower() or "german" in model2_param.lower(): lang = "German" else: lang = "English" models_list = list(AVAILABLE_MODELS[lang].keys()) p_model = resolve_model_label(lang, model_param) or models_list[0] p_model2 = resolve_model_label(lang, model2_param) or (models_list[1] if len(models_list) > 1 else models_list[0]) p_sent_up = sentence_param if sentence_param else gr.update() p_lang_up = gr.update(choices=["English", "German"], value=lang) p_model_up = gr.update(choices=models_list, value=p_model) p_morph_up = gr.update(value=morph) p_vis = gr.update() p_table = gr.update() p_raw = gr.update() p_status = gr.update() p_diff_group = gr.update() p_diff_table = gr.update() if target_tab == "tab_parser" and sentence_param: v, t, r, s, dg, dt = run_single_or_compare( lang=lang, sentence=sentence_param, primary_model_name=p_model, compare_mode=False, secondary_model_name="", include_morphology=morph, cache=parse_ctrls["session_cache"].value, ) p_vis, p_table, p_raw, p_status, p_diff_group, p_diff_table = v, t, r, s, dg, dt c_sent_up = sentence_param if sentence_param else gr.update() c_lang_up = gr.update(choices=["English", "German"], value=lang) c_model1_up = gr.update(choices=models_list, value=p_model) c_model2_up = gr.update(choices=models_list, value=p_model2) c_morph_up = gr.update(value=morph) c_vis = gr.update() c_table = gr.update() c_raw = gr.update() c_status = gr.update() c_diff_group = gr.update() c_diff_table = gr.update() if target_tab == "tab_compare" and sentence_param: v, t, r, s, dg, dt = run_single_or_compare( lang=lang, sentence=sentence_param, primary_model_name=p_model, compare_mode=True, secondary_model_name=p_model2, include_morphology=morph, cache=compare_ctrls["session_cache"].value, ) c_vis, c_table, c_raw, c_status, c_diff_group, c_diff_table = v, t, r, s, dg, dt return ( gr.Tabs(selected=target_tab), p_sent_up, p_lang_up, p_model_up, p_morph_up, p_vis, p_table, p_raw, p_status, p_diff_group, p_diff_table, c_sent_up, c_lang_up, c_model1_up, c_model2_up, c_morph_up, c_vis, c_table, c_raw, c_status, c_diff_group, c_diff_table, ) load_outputs = [ tabs, parse_ctrls["sentence"], parse_ctrls["language"], parse_ctrls["primary"], parse_ctrls["include_morphology"], parse_ctrls["visualization"], parse_ctrls["table"], parse_ctrls["raw"], parse_ctrls["status"], parse_ctrls["differences"], parse_ctrls["diff_table"], compare_ctrls["sentence"], compare_ctrls["language"], compare_ctrls["primary"], compare_ctrls["secondary"], compare_ctrls["include_morphology"], compare_ctrls["visualization"], compare_ctrls["table"], compare_ctrls["raw"], compare_ctrls["status"], compare_ctrls["differences"], compare_ctrls["diff_table"], ] demo.load(load_from_request, inputs=None, outputs=load_outputs, api_name=False) if __name__ == "__main__": demo.launch(**_launch_kwargs)