Download app.py from simon-clmtd/universal-dependencies-visualizer: direct link, hf CLI and curl.
- Browser
- Download file 43.5 kB
-
https://huggingface.co/spaces/simon-clmtd/universal-dependencies-visualizer/resolve/main/app.py
- Command line
-
hf download hf://spaces/simon-clmtd/universal-dependencies-visualizer/app.py
-
curl -L -o app.py https://huggingface.co/spaces/simon-clmtd/universal-dependencies-visualizer/resolve/main/app.py
43.5 kB
| """ | |
| 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.version import __version__, __release_date__ | |
| 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 = """<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&display=swap"> | |
| <script> | |
| window.switchAppTab = function(name, action) { | |
| try { | |
| const tabs = Array.from(document.querySelectorAll('button[role="tab"]')); | |
| const target = tabs.find(function(t) { | |
| const txt = (t.textContent || '').trim().toLowerCase(); | |
| const search = name.trim().toLowerCase(); | |
| return txt === search || txt.startsWith(search); | |
| }); | |
| if (target) { | |
| target.click(); | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| setTimeout(window.updateShareUrl, 200); | |
| // Execute tab action (e.g. trigger parsing, open help) | |
| setTimeout(function() { | |
| const panel = Array.from(document.querySelectorAll('[role="tabpanel"]')) | |
| .find(function(el) { return el.getBoundingClientRect().height > 0; }); | |
| if (!panel) return; | |
| if (name.toLowerCase().startsWith('parse')) { | |
| const submitBtn = panel.querySelector('button.primary, button[variant="primary"]'); | |
| if (submitBtn) submitBtn.click(); | |
| if (action === 'help') { | |
| var pollCount = 0; | |
| var checkHelp = setInterval(function() { | |
| pollCount++; | |
| var iframe = panel.querySelector('iframe[id^="vis-"]'); | |
| if (iframe && iframe.contentDocument) { | |
| var helpElem = iframe.contentDocument.querySelector('.ud-help'); | |
| if (helpElem) { | |
| helpElem.open = true; | |
| helpElem.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| iframe.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| clearInterval(checkHelp); | |
| return; | |
| } | |
| } | |
| if (pollCount > 50) clearInterval(checkHelp); | |
| }, 200); | |
| } | |
| } else if (name.toLowerCase().startsWith('compare')) { | |
| const submitBtn = panel.querySelector('button.primary, button[variant="primary"]'); | |
| if (submitBtn) submitBtn.click(); | |
| } | |
| }, 100); | |
| } | |
| } catch (e) { | |
| console.error('Error switching tab:', e); | |
| } | |
| }; | |
| window.updateShareUrl = function() { | |
| try { | |
| const activeTabBtn = document.querySelector('button[role="tab"][aria-selected="true"]'); | |
| if (!activeTabBtn) return; | |
| const activeTabText = activeTabBtn.textContent.trim().toLowerCase(); | |
| const params = new URLSearchParams(); | |
| if (activeTabText.startsWith('parse')) { | |
| params.set('tab', 'parse'); | |
| const panel = Array.from(document.querySelectorAll('[role="tabpanel"]')) | |
| .find(el => el.getBoundingClientRect().height > 0); | |
| if (panel) { | |
| const sent = panel.querySelector('.sentence-input input, .sentence-input textarea'); | |
| if (sent && sent.value.trim() && sent.value.trim() !== 'They buy and sell books.') { | |
| params.set('sentence', sent.value.trim()); | |
| } | |
| const model = panel.querySelector('input[aria-label*="UDPipe parser"]'); | |
| if (model && model.value.trim()) { | |
| params.set('model', model.value.trim()); | |
| } | |
| } | |
| } else if (activeTabText.startsWith('compare')) { | |
| params.set('tab', 'compare'); | |
| const panel = Array.from(document.querySelectorAll('[role="tabpanel"]')) | |
| .find(el => el.getBoundingClientRect().height > 0); | |
| if (panel) { | |
| const sent = panel.querySelector('.sentence-input input, .sentence-input textarea'); | |
| if (sent && sent.value.trim() && sent.value.trim() !== 'They buy and sell books.') { | |
| params.set('sentence', sent.value.trim()); | |
| } | |
| const m1 = panel.querySelector('input[aria-label*="First UDPipe parser"]'); | |
| if (m1 && m1.value.trim()) params.set('model', m1.value.trim()); | |
| const m2 = panel.querySelector('input[aria-label*="Second UDPipe parser"]'); | |
| if (m2 && m2.value.trim()) params.set('model2', m2.value.trim()); | |
| } | |
| } else if (activeTabText.startsWith('annotate')) { | |
| params.set('tab', 'export'); | |
| } else if (activeTabText.startsWith('treebank')) { | |
| params.set('tab', 'guide'); | |
| } else { | |
| // About tab: clean root url | |
| } | |
| const newSearch = params.toString() ? '?' + params.toString() : ''; | |
| const newUrl = window.location.pathname + newSearch; | |
| if (window.location.search !== newSearch) { | |
| window.history.replaceState(null, '', newUrl); | |
| } | |
| } catch (e) { | |
| // ignore | |
| } | |
| }; | |
| document.addEventListener('click', function(e) { | |
| const link = e.target.closest('a[href^="#tab-"]'); | |
| if (link) { | |
| e.preventDefault(); | |
| const hash = link.getAttribute('href'); | |
| if (hash === '#tab-parse-help') { | |
| if (window.switchAppTab) window.switchAppTab('Parse', 'help'); | |
| } else if (hash === '#tab-parse') { | |
| if (window.switchAppTab) window.switchAppTab('Parse', 'parse'); | |
| } else if (hash === '#tab-compare') { | |
| if (window.switchAppTab) window.switchAppTab('Compare', 'compare'); | |
| } else { | |
| const tabMap = { | |
| '#tab-export': 'Annotate & Export', | |
| '#tab-guide': 'Treebank Guide', | |
| '#tab-about': 'About' | |
| }; | |
| const targetName = tabMap[hash]; | |
| if (targetName && window.switchAppTab) { | |
| window.switchAppTab(targetName); | |
| } | |
| } | |
| } else { | |
| setTimeout(window.updateShareUrl, 250); | |
| } | |
| }); | |
| document.addEventListener('change', function(e) { | |
| setTimeout(window.updateShareUrl, 250); | |
| }); | |
| </script>""" | |
| 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; | |
| } | |
| /* Apply page spacing only to the outer wrapper, not loading overlays. */ | |
| .gradio-container main.fillable > .wrap { | |
| padding: 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; | |
| } | |
| /* Shared entry-page and workspace presentation. */ | |
| .about-page { max-width: 960px; margin: 24px auto; padding: 0 16px; } | |
| .about-page h1 { font-size: 1.8rem; line-height: 1.25; font-weight: 600; } | |
| .about-page p { font-size: 1.05rem; line-height: 1.65; } | |
| /* Match navigation to normal prose while retaining the active-tab indicator. */ | |
| #app-tabs button[role="tab"] { font-size: 1.05rem; font-weight: 400; } | |
| .about-start { display: inline-block; background: #334155; color: white !important; | |
| padding: 10px 18px; border-radius: 5px; text-decoration: none; margin: 8px 0 20px; } | |
| .about-features { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 28px; } | |
| .about-features h2 { font-size: 1.08rem; margin-bottom: 4px; } | |
| .about-features p { margin-top: 0; } | |
| .about-page a:focus-visible { outline: 3px solid #64748b; outline-offset: 3px; } | |
| .result-notice, .diff-alert-neutral { padding: 10px 14px; border: 1px solid var(--border-color-primary); | |
| border-radius: 5px; background: var(--background-fill-secondary); color: var(--body-text-color); } | |
| .release-info { text-align: center; color: var(--body-text-color-subdued); font-size: 0.9rem; padding: 12px; } | |
| .workspace-hint { color: var(--body-text-color-subdued); } | |
| @media (max-width: 600px) { | |
| .about-features { grid-template-columns: 1fr; } | |
| .about-page { margin-top: 16px; } | |
| } | |
| """ | |
| OVERVIEW_MD = """ | |
| <div class="about-page"> | |
| <h1>Universal Dependencies Parser & Visualizer</h1> | |
| <p>Explore and compare grammatical analyses of English and German sentences.</p> | |
| <a class="about-start" href="#tab-parse">Start parsing →</a> | |
| <p>Enter a sentence, choose a parser, and click <strong>Parse</strong>. Or select an example to run it immediately. | |
| Inspect the tree, then open <strong>Annotations</strong> or <strong>CoNLL-U</strong> for the underlying data. | |
| Select <strong>Include morphology (FEATS)</strong> before parsing if you need morphological features.</p> | |
| <div class="about-features"> | |
| <section><h2>Explore the analysis</h2><p>Hover over words and POS tags to reveal syntactic heads. | |
| Open <strong>Help</strong> below the tree for UD definitions, or click a word to search for its exact form in Grew.</p></section> | |
| <section><h2>Compare parsers</h2><p>Use <a href="#tab-compare">Compare</a> to inspect two trees stacked vertically and a table of differences. | |
| Disagreement can reflect ambiguity, annotation conventions, or parser errors.</p></section> | |
| <section><h2>Save and reuse</h2><p>Download each tree as SVG or PNG using its toolbar. | |
| Use <a href="#tab-export">Annotate & Export</a> for batch annotation, CoNLL-U files, and tree exports.</p></section> | |
| <section><h2>Choose a treebank</h2><p>The <a href="#tab-guide">Treebank Guide</a> describes the training corpora and their conventions. | |
| Parser names identify both UDPipe and the training treebank.</p></section> | |
| </div> | |
| <p>A dependency tree is an automatic analysis that can contain errors and may not be the only possible interpretation.</p> | |
| </div> | |
| """ | |
| 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 ( | |
| "<div style='color: #ef4444;'>Please enter a sentence to parse.</div>", | |
| 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"<div style='color: #ef4444;'>Error: {html.escape(str(error_a))}</div>" | |
| 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"<div class='diff-alert diff-alert-same'><strong>Same UDPipe parser selected:</strong> " | |
| f"Both parsers are set to <strong>{m_name_esc}</strong> ({len(table_a)} tokens). " | |
| f"Select a different second parser to inspect differences.</div>" | |
| ) | |
| 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 = ( | |
| "<div class='diff-alert diff-alert-diff'><strong>Comparison unavailable:</strong> " | |
| f"{html.escape(secondary_model_name)}: {html.escape(str(error_b))}</div>" | |
| ) | |
| 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"<div class='diff-alert diff-alert-same'><strong>Matching analyses:</strong> Both UDPipe parsers produced matching POS tags and syntactic dependency arcs.</div>" | |
| 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"<strong>{model_a_escaped}</strong>").replace(f"**{model_b_escaped}**", f"<strong>{model_b_escaped}</strong>") | |
| status_html = f"<div class='diff-alert diff-alert-neutral'><strong>Parser differences:</strong> {diff_text}</div>" | |
| 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("Copy link", size="sm", scale=0, min_width=90) | |
| include_morphology = gr.Checkbox(label="Include morphology (FEATS)", value=False) | |
| gr.Markdown(f"Click **{'Compare' if compare else 'Parse'}** after editing text or settings. Selecting an example runs it immediately.", | |
| elem_classes=["workspace-hint"]) | |
| with gr.Accordion("Try an example", 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() | |
| 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), | |
| "", | |
| '<div class="result-notice">Input changed—parse again to update the analysis.</div>', | |
| ) | |
| def on_settings_input(): | |
| return '<div class="result-notice">Settings changed—parse again to update the analysis.</div>' | |
| 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, status], | |
| 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, | |
| ) | |
| for control in (language, primary, secondary, include_morphology): | |
| control.input(on_settings_input, None, [status], api_name=False) | |
| inputs = [language, sentence, primary, gr.State(compare), secondary, include_morphology, session_cache] | |
| outputs = [visualization, table, raw, status, differences, diff_table] | |
| result_identity = gr.State(None) | |
| def run_with_identity(*args): | |
| return (*run_single_or_compare(*args), tuple(args[:6])) | |
| def check_result_freshness(lang, text, model, mode, second, morph, identity, message): | |
| if identity != (lang, text, model, mode, second, morph): | |
| return '<div class="result-notice">Input changed—parse again to update the analysis.</div>' | |
| return message | |
| for event in (submit.click, sentence.submit): | |
| event(run_with_identity, inputs, outputs + [result_identity], api_name=False).then( | |
| check_result_freshness, inputs[:6] + [result_identity, status], [status], 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(primary_hue="slate", neutral_hue="slate"), css=CUSTOM_CSS, head=CUSTOM_HEAD) | |
| else: | |
| _launch_kwargs.update(theme=gr.themes.Base(primary_hue="slate", neutral_hue="slate"), 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", elem_id="app-tabs") 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, | |
| ) | |
| gr.HTML( | |
| f'<footer class="release-info">Version {__version__} · {__release_date__}</footer>' | |
| ) | |
| 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) | |