Spaces:
Running
Running
Download app.py from commoncrawl/web-graph-knn: direct link, hf CLI and curl.
- Browser
- Download file 13.3 kB
-
https://huggingface.co/spaces/commoncrawl/web-graph-knn/resolve/main/app.py
- Command line
-
hf download hf://spaces/commoncrawl/web-graph-knn/app.py
-
curl -L -o app.py https://huggingface.co/spaces/commoncrawl/web-graph-knn/resolve/main/app.py
13.3 kB
| """Gradio UI for the kNN spot-check viewer. | |
| Type a hostname, get its nearest neighbours in the learned 128-d embedding space. Companion to the | |
| 2D viewer (``embedding-2d-viewer/``): that one shows *where* hosts land after a UMAP projection, | |
| this one answers *who is actually nearest* in the real space. | |
| The point of the tool is **walking** the space, not one lookup: click any neighbour to make it the | |
| new query, with a back button and a breadcrumb trail. That turns "are these neighbours sensible?" | |
| into "does the neighbourhood stay coherent two or three hops out?", which is the question that | |
| actually catches a bad embedding. | |
| Runs two ways, unchanged: | |
| * **locally** β ``WGL_KNN_INDEX=data/knn/cc_deg8_poc python knn-viewer/app.py``; | |
| * **as a HF Space** β the index arrives on a mounted volume at ``/data``, which is the default. | |
| The index loads once at module scope, not per request: it is the whole cost of a query, and a | |
| Space re-imports this module only on cold start. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| from pathlib import Path | |
| import gradio as gr | |
| # Allow running straight from a repo checkout without installing the package. | |
| _REPO_SRC = Path(__file__).resolve().parent.parent / "src" | |
| if _REPO_SRC.is_dir() and str(_REPO_SRC) not in sys.path: | |
| sys.path.insert(0, str(_REPO_SRC)) | |
| from wgl.knn.index import KnnIndex # noqa: E402 | |
| from wgl.release.identity import domain_key as to_domain_key # noqa: E402 | |
| INDEX_PATH = Path(os.environ.get("WGL_KNN_INDEX", "/data")) | |
| EXAMPLES = ["www.bundestag.de", "www.bild.de", "https://www.bahn.de/service", "portal.gov.cz"] | |
| HEADERS = ["#", "host", "cosine", "registrable domain", "same site", "open"] | |
| DATATYPES = ["number", "str", "number", "str", "str", "markdown"] | |
| INDEX = KnnIndex.load(INDEX_PATH) | |
| _META = INDEX.meta | |
| _FOOTER = ( | |
| f"**{len(INDEX):,} hosts** Β· `{_META.get('release_id', 'unknown release')}` Β· " | |
| f"model `{_META.get('model_space_version', 'unknown')}` Β· " | |
| f"index `{_META.get('index_factory', '?')}` Β· dim {_META.get('dim', '?')} Β· " | |
| f"built {str(_META.get('built_at', '?'))[:19]}" | |
| ) | |
| _SUBSET_NOTE = "" | |
| if _META.get("source_row_groups_per_shard") is not None: | |
| _SUBSET_NOTE = ( | |
| f"\n\nβ οΈ **This is a proof-of-concept subset**, not the full corpus: source rows " | |
| f"{_META.get('source_row_min'):,} to {_META.get('source_row_max'):,} of 52,913,544. " | |
| "Hosts outside that slice will read as *not in the corpus*." | |
| ) | |
| # ββ Common Crawl look βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Same palette as the release figures (``src/wgl/viz/svg.py``) and the 2D viewer: brand blue | |
| # #0050bd, navy ink #152a47, cool neutral greys, Libre Franklin + IBM Plex Mono. Gradio derives a | |
| # lot of shades from its hue ramps, so both ramps are spelled out and the tokens that matter are | |
| # then pinned explicitly; every colour has a ``_dark`` twin because the Space follows the browser. | |
| _CC_BLUE = gr.themes.Color( | |
| c50="#e8f0fb", | |
| c100="#cfe0f6", | |
| c200="#9dc0ee", | |
| c300="#6ba1e5", | |
| c400="#4a90e8", | |
| c500="#0050bd", | |
| c600="#0047a8", | |
| c700="#003d92", | |
| c800="#00306f", | |
| c900="#152a47", | |
| c950="#0d1929", | |
| ) | |
| _CC_SLATE = gr.themes.Color( | |
| c50="#f6f7fa", | |
| c100="#eef1f6", | |
| c200="#e8ecf2", | |
| c300="#dde2e8", | |
| c400="#8d99b0", | |
| c500="#6e7191", | |
| c600="#44536f", | |
| c700="#2a456d", | |
| c800="#1d3557", | |
| c900="#152a47", | |
| c950="#0d1929", | |
| ) | |
| THEME = gr.themes.Base( | |
| primary_hue=_CC_BLUE, | |
| secondary_hue=_CC_BLUE, | |
| neutral_hue=_CC_SLATE, | |
| font=( | |
| gr.themes.GoogleFont("Libre Franklin"), | |
| "system-ui", | |
| "-apple-system", | |
| "Segoe UI", | |
| "sans-serif", | |
| ), | |
| font_mono=( | |
| gr.themes.GoogleFont("IBM Plex Mono"), | |
| "ui-monospace", | |
| "SFMono-Regular", | |
| "Menlo", | |
| "monospace", | |
| ), | |
| ).set( | |
| # page + panels: surface over a recessed plane, inverted in dark | |
| body_background_fill="#f6f7fa", | |
| body_background_fill_dark="#0d1929", | |
| body_text_color="#152a47", | |
| body_text_color_dark="#ffffff", | |
| body_text_color_subdued="#6e7191", | |
| body_text_color_subdued_dark="#8d99b0", | |
| background_fill_primary="#ffffff", | |
| background_fill_primary_dark="#152a47", | |
| background_fill_secondary="#f6f7fa", | |
| background_fill_secondary_dark="#0d1929", | |
| block_background_fill="#ffffff", | |
| block_background_fill_dark="#152a47", | |
| block_border_color="#dde2e8", | |
| block_border_color_dark="#2a456d", | |
| block_label_text_color="#44536f", | |
| block_label_text_color_dark="#c3ccdb", | |
| block_title_text_color="#152a47", | |
| block_title_text_color_dark="#ffffff", | |
| border_color_primary="#dde2e8", | |
| border_color_primary_dark="#2a456d", | |
| border_color_accent="#0050bd", | |
| border_color_accent_dark="#4a90e8", | |
| code_background_fill="#f6f7fa", | |
| code_background_fill_dark="#0d1929", | |
| # accents: brand blue in light, the lighter dark-theme blue on navy | |
| color_accent="#0050bd", | |
| link_text_color="#0050bd", | |
| link_text_color_dark="#4a90e8", | |
| link_text_color_hover="#0047a8", | |
| link_text_color_hover_dark="#2895f7", | |
| link_text_color_active="#0047a8", | |
| link_text_color_active_dark="#2895f7", | |
| link_text_color_visited="#6631ff", | |
| link_text_color_visited_dark="#9353ff", | |
| loader_color="#0050bd", | |
| loader_color_dark="#4a90e8", | |
| slider_color="#0050bd", | |
| slider_color_dark="#4a90e8", | |
| # inputs | |
| input_background_fill="#ffffff", | |
| input_background_fill_dark="#0d1929", | |
| input_border_color="#dde2e8", | |
| input_border_color_dark="#2a456d", | |
| input_border_color_focus="#0050bd", | |
| input_border_color_focus_dark="#4a90e8", | |
| input_placeholder_color="#6e7191", | |
| input_placeholder_color_dark="#8d99b0", | |
| # buttons | |
| button_primary_background_fill="#0050bd", | |
| button_primary_background_fill_hover="#0047a8", | |
| button_primary_background_fill_dark="#0050bd", | |
| button_primary_background_fill_hover_dark="#4a90e8", | |
| button_primary_border_color="#0050bd", | |
| button_primary_border_color_dark="#0050bd", | |
| button_primary_text_color="#ffffff", | |
| button_primary_text_color_dark="#ffffff", | |
| button_secondary_background_fill="#f6f7fa", | |
| button_secondary_background_fill_hover="#e8ecf2", | |
| button_secondary_background_fill_dark="#152a47", | |
| button_secondary_background_fill_hover_dark="#1d3557", | |
| button_secondary_border_color="#dde2e8", | |
| button_secondary_border_color_dark="#2a456d", | |
| button_secondary_text_color="#152a47", | |
| button_secondary_text_color_dark="#ffffff", | |
| # the neighbour table, the one dense surface in the app | |
| table_border_color="#dde2e8", | |
| table_border_color_dark="#2a456d", | |
| table_even_background_fill="#f6f7fa", | |
| table_even_background_fill_dark="#0d1929", | |
| table_odd_background_fill="#ffffff", | |
| table_odd_background_fill_dark="#152a47", | |
| table_row_focus="#e8ecf2", | |
| table_row_focus_dark="#1d3557", | |
| table_text_color="#152a47", | |
| table_text_color_dark="#ffffff", | |
| # status colours, shared by both themes | |
| error_text_color="#d03b3b", | |
| error_text_color_dark="#d03b3b", | |
| ) | |
| # The few things the theme tokens do not reach: the brand rule under the title, the mono trail and | |
| # the small print in the footer. Dark values ride on Gradio's own ``.dark`` class on the wrapper. | |
| CSS = """ | |
| .gradio-container { max-width: 1180px !important; } | |
| #title h1 { margin-bottom: .35rem; padding-bottom: .35rem; letter-spacing: -.01em; | |
| border-bottom: 3px solid #0050bd; } | |
| .dark #title h1 { border-bottom-color: #4a90e8; } | |
| #crumb, #footer { font-size: .82rem; color: #6e7191; } | |
| .dark #crumb, .dark #footer { color: #8d99b0; } | |
| #crumb code, #footer code { background: transparent; padding: 0; color: #44536f; } | |
| .dark #crumb code, .dark #footer code { color: #c3ccdb; } | |
| """ | |
| def _table(result) -> list[list[object]]: # noqa: ANN001 - QueryResult, kept loose for gradio | |
| """Render a query result as dataframe rows, with the free per-neighbour metadata.""" | |
| query_domain = to_domain_key(result.query, reversed_host=False) | |
| rows: list[list[object]] = [] | |
| for neighbor in result.neighbors: | |
| domain = to_domain_key(neighbor.host, reversed_host=False) | |
| rows.append( | |
| [ | |
| neighbor.rank, | |
| neighbor.host, | |
| round(neighbor.score, 4), | |
| domain, | |
| "β" if domain and domain == query_domain else "", | |
| f"[β](https://{neighbor.host})", | |
| ] | |
| ) | |
| return rows | |
| def _breadcrumb(trail: list[str]) -> str: | |
| """Render the navigation trail.""" | |
| if len(trail) < 2: | |
| return "" | |
| shown = trail[-6:] | |
| prefix = "β¦ β " if len(trail) > len(shown) else "" | |
| return "**Trail:** " + prefix + " β ".join(f"`{h}`" for h in shown) | |
| def search(host: str, k: int, trail: list[str]) -> tuple: | |
| """Run a lookup and return every output the UI needs. | |
| Args: | |
| host: Raw user input β a bare host, a domain, or a pasted URL. | |
| k: Number of neighbours to return. | |
| trail: The navigation history so far. | |
| Returns: | |
| ``(status, rows, neighbour_hosts, trail, breadcrumb, host_box)`` β the extra state lets a | |
| row click resolve back to a hostname, and keeps the textbox in sync while walking. | |
| """ | |
| trail = list(trail or []) | |
| if not host or not host.strip(): | |
| return "Type a hostname to begin.", [], [], trail, _breadcrumb(trail), host | |
| result = INDEX.query(host, k=int(k)) | |
| if not result.found: | |
| return ( | |
| f"β **`{result.query or host.strip()}` is not in the indexed corpus.**\n\n" | |
| "That is a normal answer, not an error β the index covers a degree-filtered " | |
| "subset of the Common Crawl host graph. Tried the exact host, its `www.` variant " | |
| "and its registrable domain.", | |
| [], | |
| [], | |
| trail, | |
| _breadcrumb(trail), | |
| host, | |
| ) | |
| if not trail or trail[-1] != result.query: | |
| trail.append(result.query) | |
| via = { | |
| "exact": "exact match", | |
| "www": "matched the `www.` variant", | |
| "no-www": "matched without the `www.` prefix", | |
| "domain": "fell back to the registrable domain", | |
| }.get(result.resolved_via, result.resolved_via) | |
| status = ( | |
| f"β **`{result.query}`** β {via}, row {result.row:,}. " | |
| "*Click any row to explore that host's neighbours.*" | |
| ) | |
| hosts = [n.host for n in result.neighbors] | |
| return status, _table(result), hosts, trail, _breadcrumb(trail), result.query | |
| def follow(evt: gr.SelectData, hosts: list[str], k: int, trail: list[str]) -> tuple: | |
| """Make the clicked neighbour the new query.""" | |
| row = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index | |
| if not hosts or row is None or row >= len(hosts): | |
| return gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip() | |
| return search(hosts[row], k, trail) | |
| def go_back(k: int, trail: list[str]) -> tuple: | |
| """Step one hop back along the trail.""" | |
| trail = list(trail or []) | |
| if len(trail) < 2: | |
| return gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip() | |
| trail.pop() | |
| previous = trail.pop() | |
| return search(previous, k, trail) | |
| with gr.Blocks(title="Web graph kNN spot-check", analytics_enabled=False) as demo: | |
| neighbor_state = gr.State([]) | |
| trail_state = gr.State([]) | |
| gr.Markdown("# Web graph kNN spot-check", elem_id="title") | |
| gr.Markdown( | |
| "Nearest neighbours of a host in the learned link-prediction embedding space " | |
| "(cosine similarity on L2-normalized vectors). **Click a neighbour to walk to it.**" | |
| + _SUBSET_NOTE | |
| ) | |
| with gr.Row(): | |
| host_box = gr.Textbox( | |
| label="Host or URL", placeholder="www.example.com", scale=4, autofocus=True | |
| ) | |
| k_slider = gr.Slider(minimum=5, maximum=100, value=20, step=5, label="Neighbours", scale=1) | |
| with gr.Row(): | |
| go = gr.Button("Search", variant="primary", scale=3) | |
| back = gr.Button("β Back", scale=1) | |
| status = gr.Markdown() | |
| crumb = gr.Markdown(elem_id="crumb") | |
| table = gr.Dataframe( | |
| headers=HEADERS, | |
| datatype=DATATYPES, | |
| label="Nearest neighbours β click a row to explore it", | |
| wrap=True, | |
| interactive=False, | |
| ) | |
| gr.Examples(examples=[[e] for e in EXAMPLES], inputs=[host_box]) | |
| gr.Markdown(_FOOTER, elem_id="footer") | |
| OUTPUTS = [status, table, neighbor_state, trail_state, crumb, host_box] | |
| go.click(search, [host_box, k_slider, trail_state], OUTPUTS) | |
| host_box.submit(search, [host_box, k_slider, trail_state], OUTPUTS) | |
| k_slider.release(search, [host_box, k_slider, trail_state], OUTPUTS) | |
| table.select(follow, [neighbor_state, k_slider, trail_state], OUTPUTS) | |
| back.click(go_back, [k_slider, trail_state], OUTPUTS) | |
| if __name__ == "__main__": | |
| # Gradio 6 takes ``theme``/``css`` on launch(), not on the Blocks constructor (it warns and | |
| # drops them there). The Space runs this file as its app_file, so this is the one entry point. | |
| demo.launch(theme=THEME, css=CSS) | |