Spaces:
Sleeping
Sleeping
fix(ui): sync merge and dependency updates
Browse filesCode-only sync from GitHub commit 5c8b9dc. Data artifacts were left unchanged on the Space to avoid reuploading runtime datasets.
- PLAN.md +1 -0
- SUBMISSION.md +1 -0
- app.py +8 -2
- pyproject.toml +23 -23
- requirements.txt +20 -20
- src/dota2tuned/cli.py +10 -2
- src/dota2tuned/finetune.py +16 -15
- src/dota2tuned/modal_backend.py +22 -22
- src/dota2tuned/normalize.py +41 -0
- src/dota2tuned/recommend.py +42 -10
- src/dota2tuned/ui/gradio_app.py +656 -165
- tests/test_gradio_app.py +59 -0
- tests/test_recommend.py +33 -0
- uv.lock +44 -44
PLAN.md
CHANGED
|
@@ -141,6 +141,7 @@ All times are `America/Los_Angeles` / PDT unless noted.
|
|
| 141 |
- 2026-06-14 18:07: removed the sidebar brand subtitle and added client-side sidebar navigation synchronization with stable view IDs so page content switches on the first click while the Gradio backend visibility callback catches up.
|
| 142 |
- 2026-06-14 18:08: tightened role icon sizing inside hero-card tag pills so tag icons fit the allocated pill height instead of inheriting larger card image dimensions.
|
| 143 |
- 2026-06-14 18:30: constrained Gradio dropdown overlays to the trigger/viewport width, scoped listbox CSS to `.dota-dropdown`, hid horizontal overflow, and added a runtime clamp that repositions fixed dropdown menus on open, scroll, and resize so long hero labels no longer push menus outside the field.
|
|
|
|
| 144 |
|
| 145 |
## Adapter Eval Notes
|
| 146 |
|
|
|
|
| 141 |
- 2026-06-14 18:07: removed the sidebar brand subtitle and added client-side sidebar navigation synchronization with stable view IDs so page content switches on the first click while the Gradio backend visibility callback catches up.
|
| 142 |
- 2026-06-14 18:08: tightened role icon sizing inside hero-card tag pills so tag icons fit the allocated pill height instead of inheriting larger card image dimensions.
|
| 143 |
- 2026-06-14 18:30: constrained Gradio dropdown overlays to the trigger/viewport width, scoped listbox CSS to `.dota-dropdown`, hid horizontal overflow, and added a runtime clamp that repositions fixed dropdown menus on open, scroll, and resize so long hero labels no longer push menus outside the field.
|
| 144 |
+
- 2026-06-14 21:20: resolved the GitHub merge conflict in `src/dota2tuned/ui/gradio_app.py` by keeping the new Builds page while preserving the JS-only sidebar switching and dropdown clamp fixes. Refreshed direct dependency minimums and `uv.lock` to the latest resolved set, including Gradio `6.18.0`, FastAPI `0.137.0`, Transformers `5.12.0`, Torch `2.12.0`, Datasets `5.0.0`, and matching Modal/HF training dependency lists. Validation passed with `uv lock --check`, `uv run ruff check app.py src tests`, `uv run pytest -q`, and a Gradio app-construction smoke check.
|
| 145 |
|
| 146 |
## Adapter Eval Notes
|
| 147 |
|
SUBMISSION.md
CHANGED
|
@@ -31,6 +31,7 @@ Do not claim the Off the Grid or Local-first badges. The app uses Modal for the
|
|
| 31 |
- [x] Modal adapter inference passed.
|
| 32 |
- [x] Fine-tuned adapter is published on Hugging Face.
|
| 33 |
- [x] Compact serving artifacts are uploaded with the Space.
|
|
|
|
| 34 |
- [x] Dataset card is prepared for the Hub dataset.
|
| 35 |
- [x] Repository license is declared as Apache-2.0.
|
| 36 |
- [x] GitHub `main` is pushed and clean.
|
|
|
|
| 31 |
- [x] Modal adapter inference passed.
|
| 32 |
- [x] Fine-tuned adapter is published on Hugging Face.
|
| 33 |
- [x] Compact serving artifacts are uploaded with the Space.
|
| 34 |
+
- [x] Expanded sample artifacts include 1,604 enriched match details and 16,040 player-match rows.
|
| 35 |
- [x] Dataset card is prepared for the Hub dataset.
|
| 36 |
- [x] Repository license is declared as Apache-2.0.
|
| 37 |
- [x] GitHub `main` is pushed and clean.
|
app.py
CHANGED
|
@@ -5,10 +5,16 @@ sys.path.insert(0, str(Path(__file__).parent / "src"))
|
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
|
| 8 |
-
from dota2tuned.ui.gradio_app import APP_CSS, build_app
|
| 9 |
|
| 10 |
demo = build_app()
|
| 11 |
|
| 12 |
|
| 13 |
if __name__ == "__main__":
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
|
| 8 |
+
from dota2tuned.ui.gradio_app import APP_CSS, APP_HEAD, build_app
|
| 9 |
|
| 10 |
demo = build_app()
|
| 11 |
|
| 12 |
|
| 13 |
if __name__ == "__main__":
|
| 14 |
+
# NOTE: the load JS is attached inside build_app via demo.load(); launch(js=)
|
| 15 |
+
# is a no-op on page load in Gradio 6.18, so it is intentionally not passed.
|
| 16 |
+
demo.launch(
|
| 17 |
+
css=APP_CSS,
|
| 18 |
+
head=APP_HEAD,
|
| 19 |
+
theme=gr.themes.Soft(),
|
| 20 |
+
)
|
pyproject.toml
CHANGED
|
@@ -6,34 +6,34 @@ readme = "README.md"
|
|
| 6 |
license = "Apache-2.0"
|
| 7 |
requires-python = ">=3.11,<3.13"
|
| 8 |
dependencies = [
|
| 9 |
-
"datasets>=
|
| 10 |
-
"duckdb>=1.
|
| 11 |
-
"gradio>=
|
| 12 |
-
"httpx>=0.
|
| 13 |
-
"huggingface-hub>=1.
|
| 14 |
-
"joblib>=1.
|
| 15 |
-
"numpy>=
|
| 16 |
-
"polars>=1.
|
| 17 |
-
"pyarrow>=
|
| 18 |
-
"pydantic>=2.
|
| 19 |
-
"python-dotenv>=1.
|
| 20 |
-
"scikit-learn>=1.
|
| 21 |
-
"tenacity>=
|
| 22 |
-
"typer>=0.
|
| 23 |
]
|
| 24 |
|
| 25 |
[project.optional-dependencies]
|
| 26 |
train = [
|
| 27 |
-
"accelerate>=1.
|
| 28 |
-
"bitsandbytes>=0.
|
| 29 |
-
"peft>=0.
|
| 30 |
-
"torch>=2.
|
| 31 |
-
"transformers>=
|
| 32 |
-
"trl>=
|
| 33 |
]
|
| 34 |
-
lightgbm = ["lightgbm>=4.
|
| 35 |
-
modal = ["modal>=1.5.0", "fastapi[standard]>=0.
|
| 36 |
-
dev = ["pytest>=
|
| 37 |
|
| 38 |
[project.scripts]
|
| 39 |
dota2tuned = "dota2tuned.cli:app"
|
|
|
|
| 6 |
license = "Apache-2.0"
|
| 7 |
requires-python = ">=3.11,<3.13"
|
| 8 |
dependencies = [
|
| 9 |
+
"datasets>=5.0.0",
|
| 10 |
+
"duckdb>=1.5.3",
|
| 11 |
+
"gradio>=6.18.0",
|
| 12 |
+
"httpx>=0.28.1",
|
| 13 |
+
"huggingface-hub>=1.19.0",
|
| 14 |
+
"joblib>=1.5.3",
|
| 15 |
+
"numpy>=2.4.6",
|
| 16 |
+
"polars>=1.41.2",
|
| 17 |
+
"pyarrow>=24.0.0",
|
| 18 |
+
"pydantic>=2.13.4",
|
| 19 |
+
"python-dotenv>=1.2.2",
|
| 20 |
+
"scikit-learn>=1.9.0",
|
| 21 |
+
"tenacity>=9.1.4",
|
| 22 |
+
"typer>=0.25.1",
|
| 23 |
]
|
| 24 |
|
| 25 |
[project.optional-dependencies]
|
| 26 |
train = [
|
| 27 |
+
"accelerate>=1.14.0",
|
| 28 |
+
"bitsandbytes>=0.49.2",
|
| 29 |
+
"peft>=0.19.1",
|
| 30 |
+
"torch>=2.12.0",
|
| 31 |
+
"transformers>=5.12.0",
|
| 32 |
+
"trl>=1.6.0",
|
| 33 |
]
|
| 34 |
+
lightgbm = ["lightgbm>=4.6.0"]
|
| 35 |
+
modal = ["modal>=1.5.0", "fastapi[standard]>=0.137.0"]
|
| 36 |
+
dev = ["pytest>=9.1.0", "ruff>=0.15.17"]
|
| 37 |
|
| 38 |
[project.scripts]
|
| 39 |
dota2tuned = "dota2tuned.cli:app"
|
requirements.txt
CHANGED
|
@@ -1,23 +1,23 @@
|
|
| 1 |
-
datasets>=
|
| 2 |
-
duckdb>=1.
|
| 3 |
-
gradio>=
|
| 4 |
-
httpx>=0.
|
| 5 |
-
huggingface-hub>=1.
|
| 6 |
-
joblib>=1.
|
| 7 |
modal>=1.5.0
|
| 8 |
-
numpy>=
|
| 9 |
-
polars>=1.
|
| 10 |
-
pyarrow>=
|
| 11 |
-
pydantic>=2.
|
| 12 |
-
python-dotenv>=1.
|
| 13 |
-
scikit-learn>=1.
|
| 14 |
-
tenacity>=
|
| 15 |
-
typer>=0.
|
| 16 |
|
| 17 |
# GPU Space / training dependencies. These are safe on GPU Spaces and HF Jobs.
|
| 18 |
-
accelerate>=1.
|
| 19 |
-
bitsandbytes>=0.
|
| 20 |
-
peft>=0.
|
| 21 |
-
torch>=2.
|
| 22 |
-
transformers>=
|
| 23 |
-
trl>=
|
|
|
|
| 1 |
+
datasets>=5.0.0
|
| 2 |
+
duckdb>=1.5.3
|
| 3 |
+
gradio>=6.18.0
|
| 4 |
+
httpx>=0.28.1
|
| 5 |
+
huggingface-hub>=1.19.0
|
| 6 |
+
joblib>=1.5.3
|
| 7 |
modal>=1.5.0
|
| 8 |
+
numpy>=2.4.6
|
| 9 |
+
polars>=1.41.2
|
| 10 |
+
pyarrow>=24.0.0
|
| 11 |
+
pydantic>=2.13.4
|
| 12 |
+
python-dotenv>=1.2.2
|
| 13 |
+
scikit-learn>=1.9.0
|
| 14 |
+
tenacity>=9.1.4
|
| 15 |
+
typer>=0.25.1
|
| 16 |
|
| 17 |
# GPU Space / training dependencies. These are safe on GPU Spaces and HF Jobs.
|
| 18 |
+
accelerate>=1.14.0
|
| 19 |
+
bitsandbytes>=0.49.2
|
| 20 |
+
peft>=0.19.1
|
| 21 |
+
torch>=2.12.0
|
| 22 |
+
transformers>=5.12.0
|
| 23 |
+
trl>=1.6.0
|
src/dota2tuned/cli.py
CHANGED
|
@@ -200,10 +200,18 @@ def eval() -> None:
|
|
| 200 |
|
| 201 |
@app.command()
|
| 202 |
def serve() -> None:
|
| 203 |
-
|
|
|
|
|
|
|
| 204 |
|
| 205 |
demo = build_app()
|
| 206 |
-
demo.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
|
| 209 |
@app.command("modal-deploy")
|
|
|
|
| 200 |
|
| 201 |
@app.command()
|
| 202 |
def serve() -> None:
|
| 203 |
+
import gradio as gr
|
| 204 |
+
|
| 205 |
+
from dota2tuned.ui.gradio_app import APP_CSS, APP_HEAD
|
| 206 |
|
| 207 |
demo = build_app()
|
| 208 |
+
# Load JS is attached inside build_app via demo.load(); launch(js=) does not
|
| 209 |
+
# run on page load in Gradio 6.18.
|
| 210 |
+
demo.launch(
|
| 211 |
+
css=APP_CSS,
|
| 212 |
+
head=APP_HEAD,
|
| 213 |
+
theme=gr.themes.Soft(),
|
| 214 |
+
)
|
| 215 |
|
| 216 |
|
| 217 |
@app.command("modal-deploy")
|
src/dota2tuned/finetune.py
CHANGED
|
@@ -6,14 +6,14 @@ from typing import Any
|
|
| 6 |
from dota2tuned.config import Settings
|
| 7 |
|
| 8 |
JOB_DEPENDENCIES = [
|
| 9 |
-
"datasets",
|
| 10 |
-
"transformers",
|
| 11 |
-
"trl",
|
| 12 |
-
"peft",
|
| 13 |
-
"bitsandbytes",
|
| 14 |
-
"accelerate",
|
| 15 |
-
"
|
| 16 |
-
"hf-transfer",
|
| 17 |
]
|
| 18 |
|
| 19 |
JOB_ENV = {
|
|
@@ -26,13 +26,14 @@ JOB_ENV = {
|
|
| 26 |
|
| 27 |
TRAIN_SCRIPT = """# /// script
|
| 28 |
# dependencies = [
|
| 29 |
-
# "datasets",
|
| 30 |
-
# "transformers",
|
| 31 |
-
# "trl",
|
| 32 |
-
# "peft",
|
| 33 |
-
# "bitsandbytes",
|
| 34 |
-
# "accelerate",
|
| 35 |
-
# "
|
|
|
|
| 36 |
# ]
|
| 37 |
# ///
|
| 38 |
import torch
|
|
|
|
| 6 |
from dota2tuned.config import Settings
|
| 7 |
|
| 8 |
JOB_DEPENDENCIES = [
|
| 9 |
+
"datasets>=5.0.0",
|
| 10 |
+
"transformers>=5.12.0",
|
| 11 |
+
"trl>=1.6.0",
|
| 12 |
+
"peft>=0.19.1",
|
| 13 |
+
"bitsandbytes>=0.49.2",
|
| 14 |
+
"accelerate>=1.14.0",
|
| 15 |
+
"huggingface-hub>=1.19.0",
|
| 16 |
+
"hf-transfer>=0.1.9",
|
| 17 |
]
|
| 18 |
|
| 19 |
JOB_ENV = {
|
|
|
|
| 26 |
|
| 27 |
TRAIN_SCRIPT = """# /// script
|
| 28 |
# dependencies = [
|
| 29 |
+
# "datasets>=5.0.0",
|
| 30 |
+
# "transformers>=5.12.0",
|
| 31 |
+
# "trl>=1.6.0",
|
| 32 |
+
# "peft>=0.19.1",
|
| 33 |
+
# "bitsandbytes>=0.49.2",
|
| 34 |
+
# "accelerate>=1.14.0",
|
| 35 |
+
# "huggingface-hub>=1.19.0",
|
| 36 |
+
# "hf-transfer>=0.1.9",
|
| 37 |
# ]
|
| 38 |
# ///
|
| 39 |
import torch
|
src/dota2tuned/modal_backend.py
CHANGED
|
@@ -21,31 +21,31 @@ REMOTE_CACHE = Path("/cache")
|
|
| 21 |
REMOTE_OUTPUTS = Path("/outputs")
|
| 22 |
|
| 23 |
CORE_DEPS = [
|
| 24 |
-
"duckdb>=1.
|
| 25 |
-
"fastapi[standard]>=0.
|
| 26 |
-
"gradio>=
|
| 27 |
-
"httpx>=0.
|
| 28 |
-
"huggingface-hub>=1.
|
| 29 |
-
"joblib>=1.
|
| 30 |
-
"numpy>=
|
| 31 |
-
"polars>=1.
|
| 32 |
-
"pyarrow>=
|
| 33 |
-
"pydantic>=2.
|
| 34 |
-
"python-dotenv>=1.
|
| 35 |
-
"scikit-learn>=1.
|
| 36 |
-
"tenacity>=
|
| 37 |
]
|
| 38 |
|
| 39 |
TRAIN_DEPS = [
|
| 40 |
*CORE_DEPS,
|
| 41 |
-
"accelerate>=1.
|
| 42 |
-
"bitsandbytes>=0.
|
| 43 |
-
"datasets>=
|
| 44 |
"hf-transfer>=0.1.9",
|
| 45 |
-
"peft>=0.
|
| 46 |
-
"torch>=2.
|
| 47 |
-
"transformers>=
|
| 48 |
-
"trl>=
|
| 49 |
]
|
| 50 |
|
| 51 |
REMOTE_ENV = {
|
|
@@ -133,7 +133,7 @@ if modal is not None and settings.modal_enabled:
|
|
| 133 |
from fastapi import FastAPI
|
| 134 |
from gradio.routes import mount_gradio_app
|
| 135 |
|
| 136 |
-
from dota2tuned.ui.gradio_app import APP_CSS, build_app
|
| 137 |
|
| 138 |
demo = build_app()
|
| 139 |
return mount_gradio_app(
|
|
@@ -141,7 +141,7 @@ if modal is not None and settings.modal_enabled:
|
|
| 141 |
blocks=demo,
|
| 142 |
path="/",
|
| 143 |
css=APP_CSS,
|
| 144 |
-
|
| 145 |
)
|
| 146 |
|
| 147 |
@app.function(
|
|
|
|
| 21 |
REMOTE_OUTPUTS = Path("/outputs")
|
| 22 |
|
| 23 |
CORE_DEPS = [
|
| 24 |
+
"duckdb>=1.5.3",
|
| 25 |
+
"fastapi[standard]>=0.137.0",
|
| 26 |
+
"gradio>=6.18.0",
|
| 27 |
+
"httpx>=0.28.1",
|
| 28 |
+
"huggingface-hub>=1.19.0",
|
| 29 |
+
"joblib>=1.5.3",
|
| 30 |
+
"numpy>=2.4.6",
|
| 31 |
+
"polars>=1.41.2",
|
| 32 |
+
"pyarrow>=24.0.0",
|
| 33 |
+
"pydantic>=2.13.4",
|
| 34 |
+
"python-dotenv>=1.2.2",
|
| 35 |
+
"scikit-learn>=1.9.0",
|
| 36 |
+
"tenacity>=9.1.4",
|
| 37 |
]
|
| 38 |
|
| 39 |
TRAIN_DEPS = [
|
| 40 |
*CORE_DEPS,
|
| 41 |
+
"accelerate>=1.14.0",
|
| 42 |
+
"bitsandbytes>=0.49.2",
|
| 43 |
+
"datasets>=5.0.0",
|
| 44 |
"hf-transfer>=0.1.9",
|
| 45 |
+
"peft>=0.19.1",
|
| 46 |
+
"torch>=2.12.0",
|
| 47 |
+
"transformers>=5.12.0",
|
| 48 |
+
"trl>=1.6.0",
|
| 49 |
]
|
| 50 |
|
| 51 |
REMOTE_ENV = {
|
|
|
|
| 133 |
from fastapi import FastAPI
|
| 134 |
from gradio.routes import mount_gradio_app
|
| 135 |
|
| 136 |
+
from dota2tuned.ui.gradio_app import APP_CSS, APP_HEAD, build_app
|
| 137 |
|
| 138 |
demo = build_app()
|
| 139 |
return mount_gradio_app(
|
|
|
|
| 141 |
blocks=demo,
|
| 142 |
path="/",
|
| 143 |
css=APP_CSS,
|
| 144 |
+
head=APP_HEAD,
|
| 145 |
)
|
| 146 |
|
| 147 |
@app.function(
|
src/dota2tuned/normalize.py
CHANGED
|
@@ -30,6 +30,9 @@ SCHEMAS: dict[str, dict[str, pl.DataType]] = {
|
|
| 30 |
"secret_shop": pl.Boolean,
|
| 31 |
"side_shop": pl.Boolean,
|
| 32 |
"recipe": pl.Boolean,
|
|
|
|
|
|
|
|
|
|
| 33 |
},
|
| 34 |
"dim_patch": {
|
| 35 |
"patch_id": pl.Int64,
|
|
@@ -144,6 +147,41 @@ def normalize_hero_stats(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
| 144 |
return output
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
def normalize_items(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 148 |
output = []
|
| 149 |
for row in rows:
|
|
@@ -161,6 +199,9 @@ def normalize_items(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
| 161 |
"secret_shop": row.get("secret_shop"),
|
| 162 |
"side_shop": row.get("side_shop"),
|
| 163 |
"recipe": row.get("recipe"),
|
|
|
|
|
|
|
|
|
|
| 164 |
}
|
| 165 |
)
|
| 166 |
return output
|
|
|
|
| 30 |
"secret_shop": pl.Boolean,
|
| 31 |
"side_shop": pl.Boolean,
|
| 32 |
"recipe": pl.Boolean,
|
| 33 |
+
"attrib": pl.Utf8,
|
| 34 |
+
"notes": pl.Utf8,
|
| 35 |
+
"lore": pl.Utf8,
|
| 36 |
},
|
| 37 |
"dim_patch": {
|
| 38 |
"patch_id": pl.Int64,
|
|
|
|
| 147 |
return output
|
| 148 |
|
| 149 |
|
| 150 |
+
def _format_attrib(attrib: Any) -> str | None:
|
| 151 |
+
if not isinstance(attrib, list):
|
| 152 |
+
return None
|
| 153 |
+
parts = []
|
| 154 |
+
for entry in attrib:
|
| 155 |
+
if not isinstance(entry, dict):
|
| 156 |
+
continue
|
| 157 |
+
header = str(entry.get("header") or "").replace("%", "").strip()
|
| 158 |
+
value = entry.get("value")
|
| 159 |
+
footer = str(entry.get("footer") or "").strip()
|
| 160 |
+
if not header:
|
| 161 |
+
continue
|
| 162 |
+
if isinstance(value, list):
|
| 163 |
+
value_text = "/".join(str(item) for item in value)
|
| 164 |
+
else:
|
| 165 |
+
value_text = str(value) if value is not None else ""
|
| 166 |
+
if "{value}" in header:
|
| 167 |
+
text = header.replace("{value}", value_text)
|
| 168 |
+
else:
|
| 169 |
+
text = f"{header} {value_text}".strip()
|
| 170 |
+
if footer:
|
| 171 |
+
text = f"{text} {footer}"
|
| 172 |
+
parts.append(text.strip())
|
| 173 |
+
return "; ".join(part for part in parts if part) or None
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _format_notes(notes: Any) -> str | None:
|
| 177 |
+
if isinstance(notes, list):
|
| 178 |
+
cleaned = [str(note).strip() for note in notes if str(note).strip()]
|
| 179 |
+
return " | ".join(cleaned) or None
|
| 180 |
+
if isinstance(notes, str) and notes.strip():
|
| 181 |
+
return notes.strip()
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
def normalize_items(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 186 |
output = []
|
| 187 |
for row in rows:
|
|
|
|
| 199 |
"secret_shop": row.get("secret_shop"),
|
| 200 |
"side_shop": row.get("side_shop"),
|
| 201 |
"recipe": row.get("recipe"),
|
| 202 |
+
"attrib": _format_attrib(row.get("attrib")),
|
| 203 |
+
"notes": _format_notes(row.get("notes")),
|
| 204 |
+
"lore": str(row.get("lore")).strip() if row.get("lore") else None,
|
| 205 |
}
|
| 206 |
)
|
| 207 |
return output
|
src/dota2tuned/recommend.py
CHANGED
|
@@ -58,6 +58,7 @@ class DraftRecommender:
|
|
| 58 |
self.pairs = read_parquet(parquet_dir / "fact_hero_pair_stats.parquet")
|
| 59 |
self.players = read_parquet(parquet_dir / "fact_player_match.parquet")
|
| 60 |
self.hero_samples = self._load_hero_samples()
|
|
|
|
| 61 |
|
| 62 |
def ready(self) -> bool:
|
| 63 |
return not self.heroes.is_empty()
|
|
@@ -127,17 +128,48 @@ class DraftRecommender:
|
|
| 127 |
for row in grouped.iter_rows(named=True)
|
| 128 |
}
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def _pair_lift(self, hero_id: int, others: list[int], relation: str) -> tuple[float, int]:
|
| 131 |
-
if
|
| 132 |
return 0.0, 0
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
return 0.0, 0
|
| 140 |
-
avg =
|
| 141 |
-
games = subset.select(pl.col("games").sum()).item()
|
| 142 |
lift = float(avg - 0.5) * 0.25 if avg is not None else 0.0
|
| 143 |
-
return lift, int(games
|
|
|
|
| 58 |
self.pairs = read_parquet(parquet_dir / "fact_hero_pair_stats.parquet")
|
| 59 |
self.players = read_parquet(parquet_dir / "fact_player_match.parquet")
|
| 60 |
self.hero_samples = self._load_hero_samples()
|
| 61 |
+
self._pair_index = self._build_pair_index()
|
| 62 |
|
| 63 |
def ready(self) -> bool:
|
| 64 |
return not self.heroes.is_empty()
|
|
|
|
| 128 |
for row in grouped.iter_rows(named=True)
|
| 129 |
}
|
| 130 |
|
| 131 |
+
def _build_pair_index(
|
| 132 |
+
self,
|
| 133 |
+
) -> dict[tuple[str, int], dict[int, list[tuple[float | None, int]]]]:
|
| 134 |
+
"""Index pair stats by (relation, hero_id) -> other_hero_id -> [(win_rate, games)].
|
| 135 |
+
|
| 136 |
+
Precomputed once so ``_pair_lift`` is a handful of dict lookups instead of a
|
| 137 |
+
full ``pairs`` scan per candidate hero. Results match the previous
|
| 138 |
+
filter+aggregate exactly (mean ignores null win_rate; games sum treats
|
| 139 |
+
null as 0).
|
| 140 |
+
"""
|
| 141 |
+
index: dict[tuple[str, int], dict[int, list[tuple[float | None, int]]]] = {}
|
| 142 |
+
if self.pairs.is_empty():
|
| 143 |
+
return index
|
| 144 |
+
for row in self.pairs.iter_rows(named=True):
|
| 145 |
+
relation = str(row.get("relation") or "")
|
| 146 |
+
hero_id = int(row["hero_id"])
|
| 147 |
+
other_id = int(row["other_hero_id"])
|
| 148 |
+
win_rate = row.get("win_rate")
|
| 149 |
+
games = int(row.get("games") or 0)
|
| 150 |
+
bucket = index.setdefault((relation, hero_id), {})
|
| 151 |
+
bucket.setdefault(other_id, []).append(
|
| 152 |
+
(float(win_rate) if win_rate is not None else None, games)
|
| 153 |
+
)
|
| 154 |
+
return index
|
| 155 |
+
|
| 156 |
def _pair_lift(self, hero_id: int, others: list[int], relation: str) -> tuple[float, int]:
|
| 157 |
+
if not others:
|
| 158 |
return 0.0, 0
|
| 159 |
+
bucket = self._pair_index.get((relation, hero_id))
|
| 160 |
+
if not bucket:
|
| 161 |
+
return 0.0, 0
|
| 162 |
+
win_rates: list[float] = []
|
| 163 |
+
games = 0
|
| 164 |
+
matched = False
|
| 165 |
+
for other_id in set(others):
|
| 166 |
+
for win_rate, pair_games in bucket.get(other_id, ()):
|
| 167 |
+
matched = True
|
| 168 |
+
games += pair_games
|
| 169 |
+
if win_rate is not None:
|
| 170 |
+
win_rates.append(win_rate)
|
| 171 |
+
if not matched:
|
| 172 |
return 0.0, 0
|
| 173 |
+
avg = sum(win_rates) / len(win_rates) if win_rates else None
|
|
|
|
| 174 |
lift = float(avg - 0.5) * 0.25 if avg is not None else 0.0
|
| 175 |
+
return lift, int(games)
|
src/dota2tuned/ui/gradio_app.py
CHANGED
|
@@ -251,6 +251,24 @@ SCOPE_ICON_URLS = {
|
|
| 251 |
for scope, (accent, paths) in SCOPE_ICON_BODIES.items()
|
| 252 |
}
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
COMMON_HERO_ALIASES = {
|
| 255 |
"Anti-Mage": ["AM"],
|
| 256 |
"Ancient Apparition": ["AA"],
|
|
@@ -314,6 +332,7 @@ APP_CSS = """
|
|
| 314 |
format("woff");
|
| 315 |
font-weight: 400;
|
| 316 |
font-style: normal;
|
|
|
|
| 317 |
}
|
| 318 |
@font-face {
|
| 319 |
font-family: "Trajan Pro";
|
|
@@ -322,6 +341,7 @@ APP_CSS = """
|
|
| 322 |
format("woff");
|
| 323 |
font-weight: 700;
|
| 324 |
font-style: normal;
|
|
|
|
| 325 |
}
|
| 326 |
@font-face {
|
| 327 |
font-family: "Trajan Pro";
|
|
@@ -330,6 +350,53 @@ APP_CSS = """
|
|
| 330 |
format("woff");
|
| 331 |
font-weight: 900;
|
| 332 |
font-style: normal;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
}
|
| 334 |
.gradio-container {
|
| 335 |
max-width: none !important;
|
|
@@ -349,7 +416,17 @@ APP_CSS = """
|
|
| 349 |
max-width: none !important;
|
| 350 |
width: 100% !important;
|
| 351 |
}
|
| 352 |
-
.gradio-container
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
font-family: "Trajan Pro", "Goudy Trajan", "Noto Sans", serif !important;
|
| 354 |
letter-spacing: 0 !important;
|
| 355 |
}
|
|
@@ -368,13 +445,18 @@ APP_CSS = """
|
|
| 368 |
.gradio-container textarea,
|
| 369 |
.gradio-container input,
|
| 370 |
.gradio-container .wrap-inner {
|
| 371 |
-
border-color:
|
| 372 |
background: rgba(22, 22, 24, 0.70) !important;
|
| 373 |
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.035) !important;
|
| 374 |
}
|
| 375 |
.gradio-container textarea,
|
| 376 |
.gradio-container input {
|
| 377 |
-
color:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
}
|
| 379 |
.app-main button {
|
| 380 |
border: 1px solid rgba(255, 96, 70, 0.42) !important;
|
|
@@ -388,7 +470,7 @@ APP_CSS = """
|
|
| 388 |
.app-main button:hover {
|
| 389 |
border-color: rgba(255, 96, 70, 0.74) !important;
|
| 390 |
background:
|
| 391 |
-
linear-gradient(180deg,
|
| 392 |
transform: translateY(-1px);
|
| 393 |
}
|
| 394 |
.app-main button:active {
|
|
@@ -431,7 +513,7 @@ APP_CSS = """
|
|
| 431 |
min-height: 62px;
|
| 432 |
padding: 8px 4px 16px;
|
| 433 |
margin: 0 0 10px;
|
| 434 |
-
border-bottom: 1px solid rgba(235, 207, 135, 0.
|
| 435 |
}
|
| 436 |
.app-sidebar-brand img {
|
| 437 |
width: 34px;
|
|
@@ -448,7 +530,7 @@ APP_CSS = """
|
|
| 448 |
}
|
| 449 |
.app-sidebar-brand span {
|
| 450 |
display: block;
|
| 451 |
-
color:
|
| 452 |
font-size: 12px;
|
| 453 |
line-height: 15px;
|
| 454 |
}
|
|
@@ -464,7 +546,16 @@ APP_CSS = """
|
|
| 464 |
gap: 2px !important;
|
| 465 |
}
|
| 466 |
.app-sidebar .app-nav input[type="radio"] {
|
| 467 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
}
|
| 469 |
.app-sidebar .app-nav label {
|
| 470 |
display: flex !important;
|
|
@@ -494,13 +585,19 @@ APP_CSS = """
|
|
| 494 |
opacity: 0.72;
|
| 495 |
}
|
| 496 |
.app-sidebar .app-nav label:has(input:checked) {
|
| 497 |
-
background:
|
| 498 |
-
color:
|
|
|
|
|
|
|
| 499 |
}
|
| 500 |
.app-sidebar .app-nav label:has(input:checked)::before {
|
| 501 |
opacity: 1;
|
| 502 |
filter: drop-shadow(0 0 8px rgba(255, 96, 70, 0.45));
|
| 503 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
__NAV_ICON_CSS__
|
| 505 |
.app-main {
|
| 506 |
max-width: none !important;
|
|
@@ -519,6 +616,12 @@ __NAV_ICON_CSS__
|
|
| 519 |
.app-view > .block {
|
| 520 |
width: 100%;
|
| 521 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
.dota-dropdown {
|
| 523 |
position: relative;
|
| 524 |
z-index: 20;
|
|
@@ -526,10 +629,8 @@ __NAV_ICON_CSS__
|
|
| 526 |
.dota-dropdown:focus-within {
|
| 527 |
z-index: 2500;
|
| 528 |
}
|
| 529 |
-
.dota-dropdown [role="listbox"],
|
| 530 |
-
.dota-dropdown .options
|
| 531 |
-
.dota-dropdown .dropdown-options,
|
| 532 |
-
.dota-dropdown .svelte-select-list {
|
| 533 |
z-index: 4000 !important;
|
| 534 |
pointer-events: auto !important;
|
| 535 |
min-width: 0 !important;
|
|
@@ -537,25 +638,49 @@ __NAV_ICON_CSS__
|
|
| 537 |
overflow-x: hidden !important;
|
| 538 |
overscroll-behavior: contain;
|
| 539 |
box-sizing: border-box !important;
|
| 540 |
-
border: 1px solid
|
| 541 |
background: linear-gradient(180deg, #36363e 0%, #23262e 100%) !important;
|
| 542 |
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.58) !important;
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
.dota-dropdown
|
| 547 |
-
.dota-dropdown .
|
| 548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
max-width: 100% !important;
|
| 550 |
box-sizing: border-box !important;
|
| 551 |
}
|
| 552 |
-
.dota-dropdown [role="option"]:hover
|
| 553 |
-
.dota-dropdown .option:hover,
|
| 554 |
-
.dota-dropdown .dropdown-option:hover,
|
| 555 |
-
.dota-dropdown .svelte-select-list div:hover {
|
| 556 |
background: rgba(255, 96, 70, 0.16) !important;
|
| 557 |
color: #fff !important;
|
| 558 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
.dota-choice-option {
|
| 560 |
display: flex !important;
|
| 561 |
align-items: center !important;
|
|
@@ -686,7 +811,7 @@ __NAV_ICON_CSS__
|
|
| 686 |
height: 20px !important;
|
| 687 |
min-width: 20px !important;
|
| 688 |
padding: 0 !important;
|
| 689 |
-
border: 1px solid rgba(235, 207, 135, 0.
|
| 690 |
border-radius: 50% !important;
|
| 691 |
background: rgba(5, 6, 10, 0.72) !important;
|
| 692 |
color: #efe5bb !important;
|
|
@@ -750,22 +875,223 @@ __NAV_ICON_CSS__
|
|
| 750 |
border-radius: 50%;
|
| 751 |
}
|
| 752 |
.empty-strip {
|
| 753 |
-
color:
|
| 754 |
font-size: 13px;
|
| 755 |
padding: 7px 0;
|
| 756 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
@media (max-width: 720px) {
|
| 758 |
-
.dota-dropdown .options
|
| 759 |
-
.dota-dropdown [role="listbox"] {
|
| 760 |
left: 8px !important;
|
| 761 |
right: 8px !important;
|
| 762 |
width: calc(100vw - 16px) !important;
|
| 763 |
max-width: calc(100vw - 16px) !important;
|
| 764 |
}
|
| 765 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
""".replace("__NAV_ICON_CSS__", NAV_ICON_CSS)
|
| 767 |
|
| 768 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
| 770 |
return f"""
|
| 771 |
() => {{
|
|
@@ -785,16 +1111,21 @@ def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
|
| 785 |
"Data Freshness": "data-freshness-view"
|
| 786 |
}};
|
| 787 |
const comparableHeroName = (value) =>
|
| 788 |
-
cleanLabel(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
const syncDropdownMenus = () => {{
|
| 790 |
const margin = 8;
|
|
|
|
|
|
|
|
|
|
| 791 |
document.querySelectorAll(".dota-dropdown").forEach((dropdown) => {{
|
| 792 |
const input = dropdown.querySelector(
|
| 793 |
'input[autocomplete="off"], input[role="combobox"], input'
|
| 794 |
);
|
| 795 |
-
const listbox = dropdown.querySelector(
|
| 796 |
-
'.options[role="listbox"], [role="listbox"], .dropdown-options, .svelte-select-list'
|
| 797 |
-
);
|
| 798 |
if (!input || !listbox) return;
|
| 799 |
const rect = input.closest(".wrap, .wrap-inner, .input-container")?.getBoundingClientRect()
|
| 800 |
|| input.getBoundingClientRect();
|
|
@@ -814,6 +1145,9 @@ def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
|
| 814 |
}});
|
| 815 |
}};
|
| 816 |
const syncSidebarView = () => {{
|
|
|
|
|
|
|
|
|
|
| 817 |
const nav = document.querySelector(".app-nav");
|
| 818 |
if (!nav) return;
|
| 819 |
const checked = nav.querySelector('input[type="radio"]:checked');
|
|
@@ -822,59 +1156,71 @@ def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
|
| 822 |
Object.entries(navViews).forEach(([name, id]) => {{
|
| 823 |
const view = document.getElementById(id);
|
| 824 |
if (!view) return;
|
| 825 |
-
const
|
| 826 |
-
view.
|
| 827 |
-
view.
|
| 828 |
-
view.setAttribute("aria-hidden", visible ? "false" : "true");
|
| 829 |
}});
|
| 830 |
}};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
const decorate = () => {{
|
| 832 |
-
const
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
].join(', ');
|
| 839 |
-
const options = document.querySelectorAll(optionSelector);
|
| 840 |
-
options.forEach((option) => {{
|
| 841 |
-
if (option.dataset && option.dataset.dotaChoiceIconDecorated === "1") return;
|
| 842 |
-
const label = cleanLabel(option.textContent);
|
| 843 |
-
const icon = choiceIcons[label];
|
| 844 |
-
if (!icon || !icon.src) return;
|
| 845 |
-
option.classList.add("dota-choice-option");
|
| 846 |
-
if (icon.kind) option.classList.add(`dota-${{icon.kind}}-option`);
|
| 847 |
-
const img = document.createElement("img");
|
| 848 |
-
img.className = "dota-choice-icon";
|
| 849 |
-
img.src = icon.src;
|
| 850 |
-
img.alt = "";
|
| 851 |
-
img.loading = "lazy";
|
| 852 |
-
img.decoding = "async";
|
| 853 |
-
if (icon.kind === "hero" && label.includes(" · ")) {{
|
| 854 |
-
const [name, tagsText] = label.split(" · ", 2);
|
| 855 |
-
const labelWrap = document.createElement("span");
|
| 856 |
-
labelWrap.className = "dota-choice-label";
|
| 857 |
-
const nameEl = document.createElement("span");
|
| 858 |
-
nameEl.className = "dota-choice-name";
|
| 859 |
-
nameEl.textContent = name;
|
| 860 |
-
const tagsEl = document.createElement("span");
|
| 861 |
-
tagsEl.className = "dota-choice-tags";
|
| 862 |
-
tagsText.split(",").map((tag) => tag.trim()).filter(Boolean).forEach((tag) => {{
|
| 863 |
-
const tagEl = document.createElement("span");
|
| 864 |
-
tagEl.className = "dota-choice-tag";
|
| 865 |
-
tagEl.textContent = tag;
|
| 866 |
-
tagsEl.append(tagEl);
|
| 867 |
-
}});
|
| 868 |
-
option.textContent = "";
|
| 869 |
-
option.append(img, labelWrap);
|
| 870 |
-
labelWrap.append(nameEl, tagsEl);
|
| 871 |
-
}} else {{
|
| 872 |
-
option.prepend(img);
|
| 873 |
-
}}
|
| 874 |
-
if (option.dataset) option.dataset.dotaChoiceIconDecorated = "1";
|
| 875 |
-
}});
|
| 876 |
syncDropdownMenus();
|
| 877 |
}};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
const openDropdown = (dropdown) => {{
|
| 879 |
const input = dropdown?.querySelector(
|
| 880 |
'input[autocomplete="off"], input[role="combobox"], input'
|
|
@@ -898,8 +1244,7 @@ def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
|
| 898 |
event.stopPropagation();
|
| 899 |
openDropdown(dropdown);
|
| 900 |
setTimeout(() => {{
|
| 901 |
-
|
| 902 |
-
syncDropdownMenus();
|
| 903 |
}}, 0);
|
| 904 |
}};
|
| 905 |
const removeSelectedHero = (event) => {{
|
|
@@ -946,16 +1291,23 @@ def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
|
| 946 |
}}
|
| 947 |
setTimeout(syncSidebarView, 0);
|
| 948 |
}};
|
| 949 |
-
const observer = new MutationObserver(
|
| 950 |
observer.observe(document.body, {{ childList: true, subtree: true }});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
document.addEventListener("mousedown", openFromChevron, true);
|
| 952 |
document.addEventListener("click", activateSidebarNav, true);
|
| 953 |
document.addEventListener("change", syncSidebarView, true);
|
| 954 |
document.addEventListener("click", removeSelectedHero, true);
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
window.addEventListener("resize", syncDropdownMenus, true);
|
| 958 |
-
window.addEventListener("scroll", syncDropdownMenus, true);
|
| 959 |
decorate();
|
| 960 |
syncSidebarView();
|
| 961 |
}}
|
|
@@ -1088,6 +1440,7 @@ def _html_escape(value: object) -> str:
|
|
| 1088 |
.replace("<", "<")
|
| 1089 |
.replace(">", ">")
|
| 1090 |
.replace('"', """)
|
|
|
|
| 1091 |
)
|
| 1092 |
|
| 1093 |
|
|
@@ -1123,10 +1476,15 @@ def _hero_metadata(heroes: pl.DataFrame) -> dict[int, dict[str, str]]:
|
|
| 1123 |
for row in heroes.iter_rows(named=True):
|
| 1124 |
hero_id = int(row["hero_id"])
|
| 1125 |
hero_name = str(row.get("hero_name") or f"Hero {hero_id}")
|
|
|
|
| 1126 |
metadata[hero_id] = {
|
| 1127 |
"name": hero_name,
|
| 1128 |
"roles": _format_roles_text(row.get("roles")),
|
| 1129 |
"icon": _asset_url(row.get("icon") or row.get("img")),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1130 |
"aliases": ", ".join(_hero_aliases(hero_name)),
|
| 1131 |
}
|
| 1132 |
return metadata
|
|
@@ -1203,6 +1561,171 @@ def _item_choices(items: pl.DataFrame) -> list[tuple[str, str]]:
|
|
| 1203 |
return sorted(choices, key=lambda item: item[0])
|
| 1204 |
|
| 1205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1206 |
def _selected_hero_html(
|
| 1207 |
hero_ids: object, metadata: dict[int, dict[str, str]], target: str | None = None
|
| 1208 |
) -> str:
|
|
@@ -1306,6 +1829,7 @@ def build_app() -> gr.Blocks:
|
|
| 1306 |
hero_choices = _hero_choices(recommender.heroes)
|
| 1307 |
item_table = read_parquet(settings.parquet_dir / "dim_item.parquet")
|
| 1308 |
item_choices = _item_choices(item_table)
|
|
|
|
| 1309 |
|
| 1310 |
def hero_preview(hero_ids: list[int] | None, target: str | None = None) -> str:
|
| 1311 |
return _selected_hero_html(hero_ids or [], hero_metadata, target)
|
|
@@ -1400,26 +1924,17 @@ def build_app() -> gr.Blocks:
|
|
| 1400 |
return prediction.get("message", "Prediction unavailable.")
|
| 1401 |
return json.dumps(prediction, indent=2)
|
| 1402 |
|
| 1403 |
-
def hero_builds(hero_id: int | None
|
| 1404 |
hero_ids, unknown = _parse_heroes([hero_id] if hero_id else [], hero_name_lookup)
|
| 1405 |
if unknown:
|
| 1406 |
-
|
|
|
|
| 1407 |
if not hero_ids:
|
| 1408 |
-
return "Select a hero."
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
|
| 1412 |
-
|
| 1413 |
-
filtered = filtered.filter(pl.col("item_key") == item_key)
|
| 1414 |
-
rows = filtered.sort("purchases", descending=True).head(15).iter_rows(named=True)
|
| 1415 |
-
lines = []
|
| 1416 |
-
for row in rows:
|
| 1417 |
-
median_time = _format_item_time(row.get("median_time"))
|
| 1418 |
-
lines.append(
|
| 1419 |
-
f"- **{row.get('item_key')}** in `{row.get('time_bucket')}`: "
|
| 1420 |
-
f"{row.get('purchases')} purchases, median `{median_time}`"
|
| 1421 |
-
)
|
| 1422 |
-
return "\n".join(lines) if lines else "No observed item timings for that hero."
|
| 1423 |
|
| 1424 |
def draft_lab(enemies: list[int] | None, role: str, twist: str) -> str:
|
| 1425 |
enemy_ids, unknown = _parse_heroes(enemies, hero_name_lookup)
|
|
@@ -1459,24 +1974,24 @@ def build_app() -> gr.Blocks:
|
|
| 1459 |
"- Rule: if the evidence is thin, say so before giving the pick."
|
| 1460 |
)
|
| 1461 |
|
| 1462 |
-
def switch_view(selected: str):
|
| 1463 |
-
return tuple(gr.update(visible=label == selected) for label in NAV_OPTIONS)
|
| 1464 |
-
|
| 1465 |
with gr.Blocks(title="DOTA2Tuned") as demo:
|
| 1466 |
with gr.Sidebar(open=True, width=238, position="left", elem_classes=["app-sidebar"]):
|
| 1467 |
gr.HTML(_sidebar_brand_html())
|
| 1468 |
with gr.Column(elem_classes=["app-sidebar-nav-section"]):
|
| 1469 |
-
|
| 1470 |
choices=NAV_OPTIONS,
|
| 1471 |
value="Draft Coach",
|
| 1472 |
show_label=False,
|
| 1473 |
container=False,
|
|
|
|
|
|
|
|
|
|
| 1474 |
elem_classes=["app-nav"],
|
| 1475 |
)
|
| 1476 |
with gr.Column(elem_classes=["app-main"]):
|
| 1477 |
with gr.Column(
|
| 1478 |
-
visible=True, elem_id="draft-coach-view", elem_classes=["app-view"]
|
| 1479 |
-
)
|
| 1480 |
with gr.Row():
|
| 1481 |
allies = gr.Dropdown(
|
| 1482 |
choices=hero_choices,
|
|
@@ -1575,8 +2090,8 @@ def build_app() -> gr.Blocks:
|
|
| 1575 |
)
|
| 1576 |
|
| 1577 |
with gr.Column(
|
| 1578 |
-
visible=
|
| 1579 |
-
)
|
| 1580 |
with gr.Row():
|
| 1581 |
meta_hero = gr.Dropdown(
|
| 1582 |
choices=hero_choices,
|
|
@@ -1615,8 +2130,8 @@ def build_app() -> gr.Blocks:
|
|
| 1615 |
)
|
| 1616 |
|
| 1617 |
with gr.Column(
|
| 1618 |
-
visible=
|
| 1619 |
-
)
|
| 1620 |
tuned_question = gr.Textbox(
|
| 1621 |
label="Question",
|
| 1622 |
value=(
|
|
@@ -1646,8 +2161,8 @@ def build_app() -> gr.Blocks:
|
|
| 1646 |
)
|
| 1647 |
|
| 1648 |
with gr.Column(
|
| 1649 |
-
visible=
|
| 1650 |
-
)
|
| 1651 |
with gr.Row():
|
| 1652 |
radiant = gr.Dropdown(
|
| 1653 |
choices=hero_choices,
|
|
@@ -1697,44 +2212,28 @@ def build_app() -> gr.Blocks:
|
|
| 1697 |
)
|
| 1698 |
|
| 1699 |
with gr.Column(
|
| 1700 |
-
visible=
|
| 1701 |
-
)
|
| 1702 |
-
|
| 1703 |
-
|
| 1704 |
-
|
| 1705 |
-
|
| 1706 |
-
|
| 1707 |
-
|
| 1708 |
-
|
| 1709 |
-
|
| 1710 |
-
|
| 1711 |
-
choices=item_choices,
|
| 1712 |
-
label="Optional item filter",
|
| 1713 |
-
filterable=True,
|
| 1714 |
-
elem_classes=["dota-dropdown", "item-dropdown"],
|
| 1715 |
-
)
|
| 1716 |
-
with gr.Row():
|
| 1717 |
-
build_hero_preview = gr.HTML(hero_preview([1]))
|
| 1718 |
-
build_item_preview = gr.HTML(item_preview(None))
|
| 1719 |
-
builds_button = gr.Button("Show Builds")
|
| 1720 |
-
builds_output = gr.Markdown()
|
| 1721 |
hero.change(
|
| 1722 |
hero_single_preview,
|
| 1723 |
inputs=[hero],
|
| 1724 |
outputs=[build_hero_preview],
|
| 1725 |
api_visibility="private",
|
| 1726 |
)
|
| 1727 |
-
|
| 1728 |
-
item_preview,
|
| 1729 |
-
inputs=[build_item],
|
| 1730 |
-
outputs=[build_item_preview],
|
| 1731 |
-
api_visibility="private",
|
| 1732 |
-
)
|
| 1733 |
-
builds_button.click(hero_builds, inputs=[hero, build_item], outputs=[builds_output])
|
| 1734 |
|
| 1735 |
with gr.Column(
|
| 1736 |
-
visible=
|
| 1737 |
-
)
|
| 1738 |
lab_enemies = gr.Dropdown(
|
| 1739 |
choices=hero_choices,
|
| 1740 |
label="Enemy heroes",
|
|
@@ -1773,27 +2272,19 @@ def build_app() -> gr.Blocks:
|
|
| 1773 |
)
|
| 1774 |
|
| 1775 |
with gr.Column(
|
| 1776 |
-
visible=
|
| 1777 |
-
)
|
| 1778 |
status_button = gr.Button("Refresh")
|
| 1779 |
status_output = gr.Markdown()
|
| 1780 |
status_button.click(data_status, outputs=[status_output])
|
| 1781 |
-
nav.change(
|
| 1782 |
-
switch_view,
|
| 1783 |
-
inputs=[nav],
|
| 1784 |
-
outputs=[
|
| 1785 |
-
draft_view,
|
| 1786 |
-
hero_meta_view,
|
| 1787 |
-
tuned_model_view,
|
| 1788 |
-
match_predictor_view,
|
| 1789 |
-
builds_view,
|
| 1790 |
-
draft_lab_view,
|
| 1791 |
-
data_freshness_view,
|
| 1792 |
-
],
|
| 1793 |
-
api_visibility="private",
|
| 1794 |
-
)
|
| 1795 |
|
| 1796 |
-
|
| 1797 |
-
|
| 1798 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1799 |
return demo
|
|
|
|
| 251 |
for scope, (accent, paths) in SCOPE_ICON_BODIES.items()
|
| 252 |
}
|
| 253 |
|
| 254 |
+
PRIMARY_ATTR_INFO = {
|
| 255 |
+
"str": ("STR", "#ee8f7d"),
|
| 256 |
+
"agi": ("AGI", "#8fd19e"),
|
| 257 |
+
"int": ("INT", "#8fc8ff"),
|
| 258 |
+
"all": ("UNIVERSAL", "#c09cff"),
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
TIME_BUCKET_ORDER = ["0-10m", "10-20m", "20-30m", "30-40m", "40m+", "unknown"]
|
| 262 |
+
|
| 263 |
+
TIME_BUCKET_LABELS = {
|
| 264 |
+
"0-10m": "Laning (0-10 min)",
|
| 265 |
+
"10-20m": "Early Game (10-20 min)",
|
| 266 |
+
"20-30m": "Mid Game (20-30 min)",
|
| 267 |
+
"30-40m": "Late Game (30-40 min)",
|
| 268 |
+
"40m+": "End Game (40+ min)",
|
| 269 |
+
"unknown": "Unknown Timing",
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
COMMON_HERO_ALIASES = {
|
| 273 |
"Anti-Mage": ["AM"],
|
| 274 |
"Ancient Apparition": ["AA"],
|
|
|
|
| 332 |
format("woff");
|
| 333 |
font-weight: 400;
|
| 334 |
font-style: normal;
|
| 335 |
+
font-display: swap;
|
| 336 |
}
|
| 337 |
@font-face {
|
| 338 |
font-family: "Trajan Pro";
|
|
|
|
| 341 |
format("woff");
|
| 342 |
font-weight: 700;
|
| 343 |
font-style: normal;
|
| 344 |
+
font-display: swap;
|
| 345 |
}
|
| 346 |
@font-face {
|
| 347 |
font-family: "Trajan Pro";
|
|
|
|
| 350 |
format("woff");
|
| 351 |
font-weight: 900;
|
| 352 |
font-style: normal;
|
| 353 |
+
font-display: swap;
|
| 354 |
+
}
|
| 355 |
+
/* Dark palette: single source of truth (app is dark-only, WCAG 2.2 AA). */
|
| 356 |
+
:root {
|
| 357 |
+
--d2-bg: #0d0f13;
|
| 358 |
+
--d2-fg: #dcdedf;
|
| 359 |
+
--d2-fg-strong: #f1f3f4;
|
| 360 |
+
--d2-fg-muted: #9ba2aa;
|
| 361 |
+
--d2-gold: #d9b166;
|
| 362 |
+
--d2-gold-soft: #efe5bb;
|
| 363 |
+
--d2-red: #ff6046;
|
| 364 |
+
--d2-red-strong: #c2452f;
|
| 365 |
+
--d2-focus: #ff7a63;
|
| 366 |
+
--d2-border: #7d8893;
|
| 367 |
+
--d2-border-menu: #8a96a3;
|
| 368 |
+
--d2-scroll-thumb: #8a96a3;
|
| 369 |
+
}
|
| 370 |
+
/* Single dark palette, applied unconditionally (no ?__theme=dark redirect).
|
| 371 |
+
Map Gradio's semantic theme variables to their dark values so every built-in
|
| 372 |
+
component renders dark regardless of system preference or the .dark body
|
| 373 |
+
class. Set on .gradio-container so the subtree inherits these over body.dark
|
| 374 |
+
and :root. Branded surfaces are further styled by the !important rules below. */
|
| 375 |
+
.gradio-container,
|
| 376 |
+
.gradio-container.dark {
|
| 377 |
+
color-scheme: dark;
|
| 378 |
+
--background-fill-primary: var(--neutral-950);
|
| 379 |
+
--background-fill-secondary: var(--neutral-900);
|
| 380 |
+
--body-background-fill: var(--neutral-950);
|
| 381 |
+
--body-text-color: var(--neutral-100);
|
| 382 |
+
--body-text-color-subdued: var(--neutral-400);
|
| 383 |
+
--border-color-primary: var(--neutral-700);
|
| 384 |
+
--border-color-accent: var(--neutral-600);
|
| 385 |
+
--block-background-fill: var(--neutral-800);
|
| 386 |
+
--block-border-color: var(--neutral-700);
|
| 387 |
+
--block-label-background-fill: var(--primary-600);
|
| 388 |
+
--block-label-text-color: #fff;
|
| 389 |
+
--block-title-text-color: #fff;
|
| 390 |
+
--panel-background-fill: var(--neutral-900);
|
| 391 |
+
--panel-border-color: var(--neutral-700);
|
| 392 |
+
--input-background-fill: var(--neutral-700);
|
| 393 |
+
--input-border-color: var(--neutral-700);
|
| 394 |
+
--input-placeholder-color: var(--neutral-500);
|
| 395 |
+
--code-background-fill: var(--neutral-800);
|
| 396 |
+
--table-even-background-fill: var(--neutral-950);
|
| 397 |
+
--table-odd-background-fill: var(--neutral-900);
|
| 398 |
+
--table-border-color: var(--neutral-700);
|
| 399 |
+
--color-accent-soft: var(--neutral-700);
|
| 400 |
}
|
| 401 |
.gradio-container {
|
| 402 |
max-width: none !important;
|
|
|
|
| 416 |
max-width: none !important;
|
| 417 |
width: 100% !important;
|
| 418 |
}
|
| 419 |
+
.gradio-container button,
|
| 420 |
+
.gradio-container input,
|
| 421 |
+
.gradio-container textarea,
|
| 422 |
+
.gradio-container select,
|
| 423 |
+
.gradio-container label,
|
| 424 |
+
.gradio-container .prose,
|
| 425 |
+
.gradio-container .markdown,
|
| 426 |
+
.gradio-container h1,
|
| 427 |
+
.gradio-container h2,
|
| 428 |
+
.gradio-container h3,
|
| 429 |
+
.gradio-container h4 {
|
| 430 |
font-family: "Trajan Pro", "Goudy Trajan", "Noto Sans", serif !important;
|
| 431 |
letter-spacing: 0 !important;
|
| 432 |
}
|
|
|
|
| 445 |
.gradio-container textarea,
|
| 446 |
.gradio-container input,
|
| 447 |
.gradio-container .wrap-inner {
|
| 448 |
+
border-color: var(--d2-border) !important;
|
| 449 |
background: rgba(22, 22, 24, 0.70) !important;
|
| 450 |
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.035) !important;
|
| 451 |
}
|
| 452 |
.gradio-container textarea,
|
| 453 |
.gradio-container input {
|
| 454 |
+
color: var(--d2-gold-soft) !important;
|
| 455 |
+
}
|
| 456 |
+
.gradio-container :focus-visible {
|
| 457 |
+
outline: 2px solid var(--d2-focus) !important;
|
| 458 |
+
outline-offset: 2px !important;
|
| 459 |
+
border-radius: 2px;
|
| 460 |
}
|
| 461 |
.app-main button {
|
| 462 |
border: 1px solid rgba(255, 96, 70, 0.42) !important;
|
|
|
|
| 470 |
.app-main button:hover {
|
| 471 |
border-color: rgba(255, 96, 70, 0.74) !important;
|
| 472 |
background:
|
| 473 |
+
linear-gradient(180deg, var(--d2-red-strong), rgba(126, 43, 34, 0.98)) !important;
|
| 474 |
transform: translateY(-1px);
|
| 475 |
}
|
| 476 |
.app-main button:active {
|
|
|
|
| 513 |
min-height: 62px;
|
| 514 |
padding: 8px 4px 16px;
|
| 515 |
margin: 0 0 10px;
|
| 516 |
+
border-bottom: 1px solid rgba(235, 207, 135, 0.60);
|
| 517 |
}
|
| 518 |
.app-sidebar-brand img {
|
| 519 |
width: 34px;
|
|
|
|
| 530 |
}
|
| 531 |
.app-sidebar-brand span {
|
| 532 |
display: block;
|
| 533 |
+
color: var(--d2-fg-muted);
|
| 534 |
font-size: 12px;
|
| 535 |
line-height: 15px;
|
| 536 |
}
|
|
|
|
| 546 |
gap: 2px !important;
|
| 547 |
}
|
| 548 |
.app-sidebar .app-nav input[type="radio"] {
|
| 549 |
+
position: absolute !important;
|
| 550 |
+
width: 1px !important;
|
| 551 |
+
height: 1px !important;
|
| 552 |
+
margin: -1px !important;
|
| 553 |
+
padding: 0 !important;
|
| 554 |
+
border: 0 !important;
|
| 555 |
+
clip: rect(0 0 0 0) !important;
|
| 556 |
+
clip-path: inset(50%) !important;
|
| 557 |
+
overflow: hidden !important;
|
| 558 |
+
white-space: nowrap !important;
|
| 559 |
}
|
| 560 |
.app-sidebar .app-nav label {
|
| 561 |
display: flex !important;
|
|
|
|
| 585 |
opacity: 0.72;
|
| 586 |
}
|
| 587 |
.app-sidebar .app-nav label:has(input:checked) {
|
| 588 |
+
background: rgba(255, 96, 70, 0.10) !important;
|
| 589 |
+
color: var(--d2-red) !important;
|
| 590 |
+
box-shadow: inset 3px 0 0 var(--d2-red) !important;
|
| 591 |
+
font-weight: 900 !important;
|
| 592 |
}
|
| 593 |
.app-sidebar .app-nav label:has(input:checked)::before {
|
| 594 |
opacity: 1;
|
| 595 |
filter: drop-shadow(0 0 8px rgba(255, 96, 70, 0.45));
|
| 596 |
}
|
| 597 |
+
.app-sidebar .app-nav label:has(input:focus-visible) {
|
| 598 |
+
outline: 2px solid var(--d2-focus) !important;
|
| 599 |
+
outline-offset: -2px !important;
|
| 600 |
+
}
|
| 601 |
__NAV_ICON_CSS__
|
| 602 |
.app-main {
|
| 603 |
max-width: none !important;
|
|
|
|
| 616 |
.app-view > .block {
|
| 617 |
width: 100%;
|
| 618 |
}
|
| 619 |
+
/* All views stay mounted (visible=True); JS toggles .d2-active to switch tabs.
|
| 620 |
+
This keeps view visibility fully under our control instead of Gradio's
|
| 621 |
+
visible= toggle, which left freshly-selected tabs blank until re-selected. */
|
| 622 |
+
.app-view:not(.d2-active) {
|
| 623 |
+
display: none !important;
|
| 624 |
+
}
|
| 625 |
.dota-dropdown {
|
| 626 |
position: relative;
|
| 627 |
z-index: 20;
|
|
|
|
| 629 |
.dota-dropdown:focus-within {
|
| 630 |
z-index: 2500;
|
| 631 |
}
|
| 632 |
+
.dota-dropdown ul[role="listbox"],
|
| 633 |
+
.dota-dropdown .options {
|
|
|
|
|
|
|
| 634 |
z-index: 4000 !important;
|
| 635 |
pointer-events: auto !important;
|
| 636 |
min-width: 0 !important;
|
|
|
|
| 638 |
overflow-x: hidden !important;
|
| 639 |
overscroll-behavior: contain;
|
| 640 |
box-sizing: border-box !important;
|
| 641 |
+
border: 1px solid var(--d2-border-menu) !important;
|
| 642 |
background: linear-gradient(180deg, #36363e 0%, #23262e 100%) !important;
|
| 643 |
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.58) !important;
|
| 644 |
+
scrollbar-width: thin;
|
| 645 |
+
scrollbar-color: var(--d2-scroll-thumb) transparent;
|
| 646 |
+
}
|
| 647 |
+
.dota-dropdown ul[role="listbox"]::-webkit-scrollbar,
|
| 648 |
+
.dota-dropdown .options::-webkit-scrollbar {
|
| 649 |
+
width: 12px;
|
| 650 |
+
height: 12px;
|
| 651 |
+
}
|
| 652 |
+
.dota-dropdown ul[role="listbox"]::-webkit-scrollbar-track,
|
| 653 |
+
.dota-dropdown .options::-webkit-scrollbar-track {
|
| 654 |
+
background: transparent;
|
| 655 |
+
}
|
| 656 |
+
.dota-dropdown ul[role="listbox"]::-webkit-scrollbar-thumb,
|
| 657 |
+
.dota-dropdown .options::-webkit-scrollbar-thumb {
|
| 658 |
+
background: var(--d2-scroll-thumb);
|
| 659 |
+
border: 3px solid transparent;
|
| 660 |
+
background-clip: padding-box;
|
| 661 |
+
border-radius: 8px;
|
| 662 |
+
}
|
| 663 |
+
.dota-dropdown [role="option"] {
|
| 664 |
+
color: var(--d2-fg) !important;
|
| 665 |
max-width: 100% !important;
|
| 666 |
box-sizing: border-box !important;
|
| 667 |
}
|
| 668 |
+
.dota-dropdown [role="option"]:hover {
|
|
|
|
|
|
|
|
|
|
| 669 |
background: rgba(255, 96, 70, 0.16) !important;
|
| 670 |
color: #fff !important;
|
| 671 |
}
|
| 672 |
+
/* Rich option rendering is additive: we inject an icon + label as new child
|
| 673 |
+
nodes and collapse Gradio's own text node/checkmark with font-size:0 instead
|
| 674 |
+
of deleting them, so Svelte's reconciliation never touches removed nodes. */
|
| 675 |
+
.dota-dropdown li[role="option"].dota-decorated-rich {
|
| 676 |
+
font-size: 0 !important;
|
| 677 |
+
line-height: 0 !important;
|
| 678 |
+
}
|
| 679 |
+
.dota-dropdown li[role="option"].dota-decorated-rich > .dota-choice-icon,
|
| 680 |
+
.dota-dropdown li[role="option"].dota-decorated-rich > .dota-choice-label {
|
| 681 |
+
font-size: 13px;
|
| 682 |
+
line-height: 1.25;
|
| 683 |
+
}
|
| 684 |
.dota-choice-option {
|
| 685 |
display: flex !important;
|
| 686 |
align-items: center !important;
|
|
|
|
| 811 |
height: 20px !important;
|
| 812 |
min-width: 20px !important;
|
| 813 |
padding: 0 !important;
|
| 814 |
+
border: 1px solid rgba(235, 207, 135, 0.70) !important;
|
| 815 |
border-radius: 50% !important;
|
| 816 |
background: rgba(5, 6, 10, 0.72) !important;
|
| 817 |
color: #efe5bb !important;
|
|
|
|
| 875 |
border-radius: 50%;
|
| 876 |
}
|
| 877 |
.empty-strip {
|
| 878 |
+
color: var(--d2-fg-muted);
|
| 879 |
font-size: 13px;
|
| 880 |
padding: 7px 0;
|
| 881 |
}
|
| 882 |
+
.build-hero-header {
|
| 883 |
+
position: relative;
|
| 884 |
+
display: flex;
|
| 885 |
+
align-items: center;
|
| 886 |
+
gap: 14px;
|
| 887 |
+
padding: 16px 18px;
|
| 888 |
+
border: 1px solid rgba(235, 207, 135, 0.4);
|
| 889 |
+
border-radius: 4px;
|
| 890 |
+
background-color: rgba(22, 22, 24, 0.78);
|
| 891 |
+
background-size: cover;
|
| 892 |
+
background-position: center 18%;
|
| 893 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
| 894 |
+
overflow: hidden;
|
| 895 |
+
}
|
| 896 |
+
.build-hero-header::after {
|
| 897 |
+
content: "";
|
| 898 |
+
position: absolute;
|
| 899 |
+
inset: 0;
|
| 900 |
+
background: linear-gradient(
|
| 901 |
+
90deg,
|
| 902 |
+
rgba(10, 11, 14, 0.92) 0%,
|
| 903 |
+
rgba(10, 11, 14, 0.55) 55%,
|
| 904 |
+
rgba(10, 11, 14, 0.85) 100%
|
| 905 |
+
);
|
| 906 |
+
pointer-events: none;
|
| 907 |
+
}
|
| 908 |
+
.build-hero-header .build-hero-icon {
|
| 909 |
+
position: relative;
|
| 910 |
+
z-index: 1;
|
| 911 |
+
width: 64px;
|
| 912 |
+
height: 64px;
|
| 913 |
+
object-fit: cover;
|
| 914 |
+
border-radius: 6px;
|
| 915 |
+
flex: 0 0 64px;
|
| 916 |
+
box-shadow: 0 0 0 1px rgba(235, 207, 135, 0.4), 0 6px 18px rgba(0, 0, 0, 0.4);
|
| 917 |
+
}
|
| 918 |
+
.build-hero-title {
|
| 919 |
+
position: relative;
|
| 920 |
+
z-index: 1;
|
| 921 |
+
display: flex;
|
| 922 |
+
flex-direction: column;
|
| 923 |
+
gap: 6px;
|
| 924 |
+
min-width: 0;
|
| 925 |
+
}
|
| 926 |
+
.build-hero-title strong {
|
| 927 |
+
font-size: 20px;
|
| 928 |
+
line-height: 24px;
|
| 929 |
+
color: #efe5bb;
|
| 930 |
+
}
|
| 931 |
+
.build-stat-row {
|
| 932 |
+
display: flex;
|
| 933 |
+
flex-wrap: wrap;
|
| 934 |
+
gap: 6px;
|
| 935 |
+
}
|
| 936 |
+
.build-stat-pill {
|
| 937 |
+
display: inline-flex;
|
| 938 |
+
align-items: center;
|
| 939 |
+
padding: 2px 9px;
|
| 940 |
+
border-radius: 12px;
|
| 941 |
+
border: 1px solid var(--pill-color, rgba(235, 207, 135, 0.4));
|
| 942 |
+
background: rgba(235, 207, 135, 0.10);
|
| 943 |
+
color: #efe5bb;
|
| 944 |
+
font-size: 11px;
|
| 945 |
+
line-height: 16px;
|
| 946 |
+
}
|
| 947 |
+
.build-columns {
|
| 948 |
+
display: grid;
|
| 949 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 950 |
+
gap: 12px;
|
| 951 |
+
margin-top: 4px;
|
| 952 |
+
}
|
| 953 |
+
.build-column {
|
| 954 |
+
border: 1px solid rgba(217, 177, 102, 0.3);
|
| 955 |
+
border-radius: 4px;
|
| 956 |
+
padding: 12px;
|
| 957 |
+
background: linear-gradient(180deg, rgba(50, 51, 58, 0.78), rgba(20, 21, 25, 0.78));
|
| 958 |
+
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 4px 14px rgba(0, 0, 0, 0.28);
|
| 959 |
+
}
|
| 960 |
+
.build-column-title {
|
| 961 |
+
font-size: 12px;
|
| 962 |
+
font-weight: 700;
|
| 963 |
+
text-transform: uppercase;
|
| 964 |
+
color: #efd9a0;
|
| 965 |
+
padding-bottom: 7px;
|
| 966 |
+
margin-bottom: 9px;
|
| 967 |
+
border-bottom: 1px solid rgba(235, 207, 135, 0.32);
|
| 968 |
+
}
|
| 969 |
+
.build-item-list {
|
| 970 |
+
display: flex;
|
| 971 |
+
flex-direction: column;
|
| 972 |
+
gap: 9px;
|
| 973 |
+
}
|
| 974 |
+
.build-item {
|
| 975 |
+
display: flex;
|
| 976 |
+
align-items: center;
|
| 977 |
+
gap: 8px;
|
| 978 |
+
padding: 4px;
|
| 979 |
+
border-radius: 3px;
|
| 980 |
+
cursor: help;
|
| 981 |
+
transition: background 0.12s ease;
|
| 982 |
+
}
|
| 983 |
+
.build-item:hover {
|
| 984 |
+
background: rgba(255, 96, 70, 0.10);
|
| 985 |
+
}
|
| 986 |
+
.build-item img {
|
| 987 |
+
width: 38px;
|
| 988 |
+
height: 38px;
|
| 989 |
+
object-fit: cover;
|
| 990 |
+
border-radius: 3px;
|
| 991 |
+
flex: 0 0 38px;
|
| 992 |
+
box-shadow: 0 0 0 1px rgba(235, 207, 135, 0.32);
|
| 993 |
+
}
|
| 994 |
+
.build-item-order {
|
| 995 |
+
display: inline-flex;
|
| 996 |
+
align-items: center;
|
| 997 |
+
justify-content: center;
|
| 998 |
+
width: 18px;
|
| 999 |
+
height: 18px;
|
| 1000 |
+
flex: 0 0 18px;
|
| 1001 |
+
border-radius: 50%;
|
| 1002 |
+
border: 1px solid rgba(235, 207, 135, 0.5);
|
| 1003 |
+
background: rgba(5, 6, 10, 0.6);
|
| 1004 |
+
color: #efe5bb;
|
| 1005 |
+
font-size: 10px;
|
| 1006 |
+
line-height: 16px;
|
| 1007 |
+
font-weight: 700;
|
| 1008 |
+
}
|
| 1009 |
+
.build-item-meta {
|
| 1010 |
+
display: flex;
|
| 1011 |
+
flex-direction: column;
|
| 1012 |
+
gap: 2px;
|
| 1013 |
+
min-width: 0;
|
| 1014 |
+
flex: 1 1 auto;
|
| 1015 |
+
}
|
| 1016 |
+
.build-item-meta strong {
|
| 1017 |
+
font-size: 13px;
|
| 1018 |
+
line-height: 16px;
|
| 1019 |
+
color: #f6f3e9;
|
| 1020 |
+
overflow: hidden;
|
| 1021 |
+
text-overflow: ellipsis;
|
| 1022 |
+
white-space: nowrap;
|
| 1023 |
+
}
|
| 1024 |
+
.build-item-meta span {
|
| 1025 |
+
font-size: 11px;
|
| 1026 |
+
line-height: 14px;
|
| 1027 |
+
color: #a3aab2;
|
| 1028 |
+
}
|
| 1029 |
+
.build-item-bar {
|
| 1030 |
+
height: 4px;
|
| 1031 |
+
border-radius: 2px;
|
| 1032 |
+
background: rgba(103, 112, 123, 0.32);
|
| 1033 |
+
overflow: hidden;
|
| 1034 |
+
}
|
| 1035 |
+
.build-item-bar span {
|
| 1036 |
+
display: block;
|
| 1037 |
+
height: 100%;
|
| 1038 |
+
background: linear-gradient(90deg, rgba(255, 96, 70, 0.9), rgba(217, 177, 102, 0.9));
|
| 1039 |
+
}
|
| 1040 |
+
.build-column-highlight {
|
| 1041 |
+
border-color: rgba(235, 207, 135, 0.55);
|
| 1042 |
+
background: linear-gradient(180deg, rgba(94, 68, 32, 0.42), rgba(22, 22, 24, 0.7));
|
| 1043 |
+
margin-top: 4px;
|
| 1044 |
+
box-shadow: 0 0 0 1px rgba(235, 207, 135, 0.12), 0 4px 14px rgba(0, 0, 0, 0.28);
|
| 1045 |
+
}
|
| 1046 |
+
.build-column-highlight .build-column-title {
|
| 1047 |
+
font-size: 13px;
|
| 1048 |
+
color: #ffd98c;
|
| 1049 |
+
}
|
| 1050 |
+
.build-item-list-row {
|
| 1051 |
+
display: grid;
|
| 1052 |
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
| 1053 |
+
gap: 10px;
|
| 1054 |
+
}
|
| 1055 |
+
.build-item-highlight img {
|
| 1056 |
+
width: 48px;
|
| 1057 |
+
height: 48px;
|
| 1058 |
+
flex: 0 0 48px;
|
| 1059 |
+
box-shadow: 0 0 0 1px rgba(235, 207, 135, 0.5), 0 4px 10px rgba(0, 0, 0, 0.3);
|
| 1060 |
+
}
|
| 1061 |
@media (max-width: 720px) {
|
| 1062 |
+
.dota-dropdown .options,
|
| 1063 |
+
.dota-dropdown ul[role="listbox"] {
|
| 1064 |
left: 8px !important;
|
| 1065 |
right: 8px !important;
|
| 1066 |
width: calc(100vw - 16px) !important;
|
| 1067 |
max-width: calc(100vw - 16px) !important;
|
| 1068 |
}
|
| 1069 |
}
|
| 1070 |
+
@media (prefers-reduced-motion: reduce) {
|
| 1071 |
+
.gradio-container *,
|
| 1072 |
+
.gradio-container *::before,
|
| 1073 |
+
.gradio-container *::after {
|
| 1074 |
+
transition-duration: 0.001ms !important;
|
| 1075 |
+
animation-duration: 0.001ms !important;
|
| 1076 |
+
transition-delay: 0ms !important;
|
| 1077 |
+
scroll-behavior: auto !important;
|
| 1078 |
+
}
|
| 1079 |
+
.app-main button:hover {
|
| 1080 |
+
transform: none !important;
|
| 1081 |
+
}
|
| 1082 |
+
}
|
| 1083 |
""".replace("__NAV_ICON_CSS__", NAV_ICON_CSS)
|
| 1084 |
|
| 1085 |
|
| 1086 |
+
# Injected into <head>. Preconnect to the asset CDNs. Dark mode is a single CSS
|
| 1087 |
+
# palette (see the .gradio-container theme-variable overrides in APP_CSS), so no
|
| 1088 |
+
# ?__theme=dark redirect is needed.
|
| 1089 |
+
APP_HEAD = """
|
| 1090 |
+
<link rel="preconnect" href="https://cdn.steamstatic.com" crossorigin>
|
| 1091 |
+
<link rel="preconnect" href="https://cdn.cloudflare.steamstatic.com" crossorigin>
|
| 1092 |
+
"""
|
| 1093 |
+
|
| 1094 |
+
|
| 1095 |
def _dropdown_js(choice_icon_by_label: dict[str, dict[str, str]]) -> str:
|
| 1096 |
return f"""
|
| 1097 |
() => {{
|
|
|
|
| 1111 |
"Data Freshness": "data-freshness-view"
|
| 1112 |
}};
|
| 1113 |
const comparableHeroName = (value) =>
|
| 1114 |
+
cleanLabel(value)
|
| 1115 |
+
.split(" · ", 1)[0]
|
| 1116 |
+
.replace(/\\([^)]*\\)/g, " ")
|
| 1117 |
+
.toLowerCase()
|
| 1118 |
+
.replace(/[^a-z0-9]+/g, "");
|
| 1119 |
const syncDropdownMenus = () => {{
|
| 1120 |
const margin = 8;
|
| 1121 |
+
if (!document.querySelector('.dota-dropdown .options, .dota-dropdown ul[role="listbox"]')) {{
|
| 1122 |
+
return;
|
| 1123 |
+
}}
|
| 1124 |
document.querySelectorAll(".dota-dropdown").forEach((dropdown) => {{
|
| 1125 |
const input = dropdown.querySelector(
|
| 1126 |
'input[autocomplete="off"], input[role="combobox"], input'
|
| 1127 |
);
|
| 1128 |
+
const listbox = dropdown.querySelector('.options, ul[role="listbox"]');
|
|
|
|
|
|
|
| 1129 |
if (!input || !listbox) return;
|
| 1130 |
const rect = input.closest(".wrap, .wrap-inner, .input-container")?.getBoundingClientRect()
|
| 1131 |
|| input.getBoundingClientRect();
|
|
|
|
| 1145 |
}});
|
| 1146 |
}};
|
| 1147 |
const syncSidebarView = () => {{
|
| 1148 |
+
// Views are always rendered; we own visibility via the .d2-active class
|
| 1149 |
+
// (CSS hides the rest). We never set inline display/hidden, which would
|
| 1150 |
+
// fight Svelte and leave a freshly-selected tab blank until re-selected.
|
| 1151 |
const nav = document.querySelector(".app-nav");
|
| 1152 |
if (!nav) return;
|
| 1153 |
const checked = nav.querySelector('input[type="radio"]:checked');
|
|
|
|
| 1156 |
Object.entries(navViews).forEach(([name, id]) => {{
|
| 1157 |
const view = document.getElementById(id);
|
| 1158 |
if (!view) return;
|
| 1159 |
+
const active = name === selected;
|
| 1160 |
+
view.classList.toggle("d2-active", active);
|
| 1161 |
+
view.setAttribute("aria-hidden", active ? "false" : "true");
|
|
|
|
| 1162 |
}});
|
| 1163 |
}};
|
| 1164 |
+
const decorateOption = (option) => {{
|
| 1165 |
+
if (option.dataset && option.dataset.dotaChoiceIconDecorated === "1") return;
|
| 1166 |
+
// Gradio 6.18 prefixes option text with a "✓" checkmark glyph; the clean
|
| 1167 |
+
// choice label lives in aria-label, which is what choiceIcons is keyed on.
|
| 1168 |
+
const label = cleanLabel(option.getAttribute("aria-label") || option.textContent);
|
| 1169 |
+
const icon = choiceIcons[label];
|
| 1170 |
+
if (!icon || !icon.src) return;
|
| 1171 |
+
option.classList.add("dota-choice-option");
|
| 1172 |
+
if (icon.kind) option.classList.add(`dota-${{icon.kind}}-option`);
|
| 1173 |
+
const img = document.createElement("img");
|
| 1174 |
+
img.className = "dota-choice-icon";
|
| 1175 |
+
img.src = icon.src;
|
| 1176 |
+
img.alt = "";
|
| 1177 |
+
img.loading = "lazy";
|
| 1178 |
+
img.decoding = "async";
|
| 1179 |
+
if (icon.kind === "hero" && label.includes(" · ")) {{
|
| 1180 |
+
const [name, tagsText] = label.split(" · ", 2);
|
| 1181 |
+
const labelWrap = document.createElement("span");
|
| 1182 |
+
labelWrap.className = "dota-choice-label";
|
| 1183 |
+
const nameEl = document.createElement("span");
|
| 1184 |
+
nameEl.className = "dota-choice-name";
|
| 1185 |
+
nameEl.textContent = name;
|
| 1186 |
+
const tagsEl = document.createElement("span");
|
| 1187 |
+
tagsEl.className = "dota-choice-tags";
|
| 1188 |
+
tagsText.split(",").map((tag) => tag.trim()).filter(Boolean).forEach((tag) => {{
|
| 1189 |
+
const tagEl = document.createElement("span");
|
| 1190 |
+
tagEl.className = "dota-choice-tag";
|
| 1191 |
+
tagEl.textContent = tag;
|
| 1192 |
+
tagsEl.append(tagEl);
|
| 1193 |
+
}});
|
| 1194 |
+
labelWrap.append(nameEl, tagsEl);
|
| 1195 |
+
// Additive only: never delete Gradio/Svelte-owned nodes (textContent="").
|
| 1196 |
+
// CSS (.dota-decorated-rich) collapses the original text node + checkmark,
|
| 1197 |
+
// so Svelte's per-option reactive effect never reconciles a removed node.
|
| 1198 |
+
option.prepend(img);
|
| 1199 |
+
option.append(labelWrap);
|
| 1200 |
+
option.classList.add("dota-decorated-rich");
|
| 1201 |
+
}} else {{
|
| 1202 |
+
option.prepend(img);
|
| 1203 |
+
}}
|
| 1204 |
+
if (option.dataset) option.dataset.dotaChoiceIconDecorated = "1";
|
| 1205 |
+
}};
|
| 1206 |
const decorate = () => {{
|
| 1207 |
+
const options = document.querySelectorAll('li[role="option"], [role="option"]');
|
| 1208 |
+
if (options.length) {{
|
| 1209 |
+
observer.disconnect();
|
| 1210 |
+
options.forEach(decorateOption);
|
| 1211 |
+
observer.observe(document.body, {{ childList: true, subtree: true }});
|
| 1212 |
+
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1213 |
syncDropdownMenus();
|
| 1214 |
}};
|
| 1215 |
+
let decorateScheduled = false;
|
| 1216 |
+
const scheduleDecorate = () => {{
|
| 1217 |
+
if (decorateScheduled) return;
|
| 1218 |
+
decorateScheduled = true;
|
| 1219 |
+
requestAnimationFrame(() => {{
|
| 1220 |
+
decorateScheduled = false;
|
| 1221 |
+
decorate();
|
| 1222 |
+
}});
|
| 1223 |
+
}};
|
| 1224 |
const openDropdown = (dropdown) => {{
|
| 1225 |
const input = dropdown?.querySelector(
|
| 1226 |
'input[autocomplete="off"], input[role="combobox"], input'
|
|
|
|
| 1244 |
event.stopPropagation();
|
| 1245 |
openDropdown(dropdown);
|
| 1246 |
setTimeout(() => {{
|
| 1247 |
+
scheduleDecorate();
|
|
|
|
| 1248 |
}}, 0);
|
| 1249 |
}};
|
| 1250 |
const removeSelectedHero = (event) => {{
|
|
|
|
| 1291 |
}}
|
| 1292 |
setTimeout(syncSidebarView, 0);
|
| 1293 |
}};
|
| 1294 |
+
const observer = new MutationObserver(scheduleDecorate);
|
| 1295 |
observer.observe(document.body, {{ childList: true, subtree: true }});
|
| 1296 |
+
let syncScheduled = false;
|
| 1297 |
+
const scheduleSync = () => {{
|
| 1298 |
+
if (syncScheduled) return;
|
| 1299 |
+
syncScheduled = true;
|
| 1300 |
+
requestAnimationFrame(() => {{
|
| 1301 |
+
syncScheduled = false;
|
| 1302 |
+
syncDropdownMenus();
|
| 1303 |
+
}});
|
| 1304 |
+
}};
|
| 1305 |
document.addEventListener("mousedown", openFromChevron, true);
|
| 1306 |
document.addEventListener("click", activateSidebarNav, true);
|
| 1307 |
document.addEventListener("change", syncSidebarView, true);
|
| 1308 |
document.addEventListener("click", removeSelectedHero, true);
|
| 1309 |
+
window.addEventListener("resize", scheduleSync, {{ passive: true }});
|
| 1310 |
+
window.addEventListener("scroll", scheduleSync, {{ passive: true, capture: true }});
|
|
|
|
|
|
|
| 1311 |
decorate();
|
| 1312 |
syncSidebarView();
|
| 1313 |
}}
|
|
|
|
| 1440 |
.replace("<", "<")
|
| 1441 |
.replace(">", ">")
|
| 1442 |
.replace('"', """)
|
| 1443 |
+
.replace("'", "'")
|
| 1444 |
)
|
| 1445 |
|
| 1446 |
|
|
|
|
| 1476 |
for row in heroes.iter_rows(named=True):
|
| 1477 |
hero_id = int(row["hero_id"])
|
| 1478 |
hero_name = str(row.get("hero_name") or f"Hero {hero_id}")
|
| 1479 |
+
pro_win_rate = row.get("pro_win_rate")
|
| 1480 |
metadata[hero_id] = {
|
| 1481 |
"name": hero_name,
|
| 1482 |
"roles": _format_roles_text(row.get("roles")),
|
| 1483 |
"icon": _asset_url(row.get("icon") or row.get("img")),
|
| 1484 |
+
"image": _asset_url(row.get("img") or row.get("icon")),
|
| 1485 |
+
"primary_attr": str(row.get("primary_attr") or "").lower(),
|
| 1486 |
+
"pro_pick": int(row.get("pro_pick") or 0),
|
| 1487 |
+
"pro_win_rate": float(pro_win_rate) if pro_win_rate is not None else None,
|
| 1488 |
"aliases": ", ".join(_hero_aliases(hero_name)),
|
| 1489 |
}
|
| 1490 |
return metadata
|
|
|
|
| 1561 |
return sorted(choices, key=lambda item: item[0])
|
| 1562 |
|
| 1563 |
|
| 1564 |
+
def _item_tooltip(item_name: str, item_row: dict[str, object]) -> str:
|
| 1565 |
+
lines = [item_name]
|
| 1566 |
+
cost = item_row.get("cost")
|
| 1567 |
+
if cost:
|
| 1568 |
+
lines.append(f"Cost: {int(cost):,}g")
|
| 1569 |
+
attrib = item_row.get("attrib")
|
| 1570 |
+
if attrib:
|
| 1571 |
+
lines.append(str(attrib))
|
| 1572 |
+
notes = item_row.get("notes")
|
| 1573 |
+
if notes:
|
| 1574 |
+
lines.append(str(notes))
|
| 1575 |
+
return _html_escape("\n".join(lines))
|
| 1576 |
+
|
| 1577 |
+
|
| 1578 |
+
def _item_metadata(items: pl.DataFrame) -> dict[str, dict[str, object]]:
|
| 1579 |
+
metadata: dict[str, dict[str, object]] = {}
|
| 1580 |
+
if items.is_empty():
|
| 1581 |
+
return metadata
|
| 1582 |
+
for row in items.iter_rows(named=True):
|
| 1583 |
+
key = str(row.get("item_key") or "")
|
| 1584 |
+
if not key:
|
| 1585 |
+
continue
|
| 1586 |
+
metadata[key] = {
|
| 1587 |
+
"name": str(row.get("item_name") or key.replace("_", " ").title()),
|
| 1588 |
+
"cost": row.get("cost"),
|
| 1589 |
+
"attrib": row.get("attrib"),
|
| 1590 |
+
"notes": row.get("notes"),
|
| 1591 |
+
}
|
| 1592 |
+
return metadata
|
| 1593 |
+
|
| 1594 |
+
|
| 1595 |
+
def _build_hero_header_html(hero_id: int, metadata: dict[int, dict[str, object]]) -> str:
|
| 1596 |
+
row = metadata.get(hero_id, {})
|
| 1597 |
+
name = _html_escape(row.get("name") or f"Hero {hero_id}")
|
| 1598 |
+
icon = _html_escape(row.get("icon") or "")
|
| 1599 |
+
image = _html_escape(row.get("image") or "")
|
| 1600 |
+
attr_key = str(row.get("primary_attr") or "")
|
| 1601 |
+
attr_label, attr_color = PRIMARY_ATTR_INFO.get(attr_key, ("", ""))
|
| 1602 |
+
pills = []
|
| 1603 |
+
if attr_label:
|
| 1604 |
+
style = f" style='--pill-color: {attr_color}'" if attr_color else ""
|
| 1605 |
+
pills.append(f"<span class='build-stat-pill'{style}>{attr_label}</span>")
|
| 1606 |
+
pro_win_rate = row.get("pro_win_rate")
|
| 1607 |
+
if pro_win_rate is not None:
|
| 1608 |
+
pills.append(f"<span class='build-stat-pill'>Pro WR {pro_win_rate * 100:.1f}%</span>")
|
| 1609 |
+
pro_pick = row.get("pro_pick") or 0
|
| 1610 |
+
if pro_pick:
|
| 1611 |
+
pills.append(f"<span class='build-stat-pill'>{pro_pick:,} pro picks</span>")
|
| 1612 |
+
icon_html = (
|
| 1613 |
+
f"<img class='build-hero-icon' src='{icon}' alt='{name}' loading='lazy'>"
|
| 1614 |
+
if icon
|
| 1615 |
+
else ""
|
| 1616 |
+
)
|
| 1617 |
+
style = f" style=\"background-image: url('{image}')\"" if image else ""
|
| 1618 |
+
return (
|
| 1619 |
+
f"<div class='build-hero-header'{style}>"
|
| 1620 |
+
f"{icon_html}"
|
| 1621 |
+
"<div class='build-hero-title'>"
|
| 1622 |
+
f"<strong>{name}</strong>"
|
| 1623 |
+
f"{_role_tags_html(row.get('roles'))}"
|
| 1624 |
+
f"<div class='build-stat-row'>{''.join(pills)}</div>"
|
| 1625 |
+
"</div></div>"
|
| 1626 |
+
)
|
| 1627 |
+
|
| 1628 |
+
|
| 1629 |
+
def _build_top_items_html(
|
| 1630 |
+
hero_id: int,
|
| 1631 |
+
build_stats: pl.DataFrame,
|
| 1632 |
+
item_metadata: dict[str, dict[str, object]],
|
| 1633 |
+
top_n: int = 6,
|
| 1634 |
+
) -> str:
|
| 1635 |
+
if build_stats.is_empty():
|
| 1636 |
+
return ""
|
| 1637 |
+
filtered = build_stats.filter(
|
| 1638 |
+
(pl.col("hero_id") == hero_id) & ~pl.col("item_key").str.starts_with("recipe_")
|
| 1639 |
+
)
|
| 1640 |
+
if filtered.is_empty():
|
| 1641 |
+
return ""
|
| 1642 |
+
totals = filtered.group_by("item_key").agg(pl.col("purchases").sum().alias("purchases"))
|
| 1643 |
+
total = totals["purchases"].sum() or 0
|
| 1644 |
+
items_html = []
|
| 1645 |
+
for row in totals.sort("purchases", descending=True).head(top_n).iter_rows(named=True):
|
| 1646 |
+
item_key = str(row["item_key"])
|
| 1647 |
+
item_row = item_metadata.get(item_key, {})
|
| 1648 |
+
item_name = _html_escape(item_row.get("name") or item_key.replace("_", " ").title())
|
| 1649 |
+
icon = _html_escape(_item_icon_url(item_key))
|
| 1650 |
+
tooltip = _item_tooltip(item_row.get("name") or item_name, item_row)
|
| 1651 |
+
purchases = int(row["purchases"])
|
| 1652 |
+
share = (purchases / total * 100) if total else 0
|
| 1653 |
+
items_html.append(
|
| 1654 |
+
f"<div class='build-item build-item-highlight' title='{tooltip}'>"
|
| 1655 |
+
f"<img src='{icon}' alt='{item_name}' loading='lazy'>"
|
| 1656 |
+
"<div class='build-item-meta'>"
|
| 1657 |
+
f"<strong>{item_name}</strong>"
|
| 1658 |
+
f"<span>{share:.0f}% of buys</span>"
|
| 1659 |
+
f"<div class='build-item-bar'><span style='width: {share:.0f}%'></span></div>"
|
| 1660 |
+
"</div></div>"
|
| 1661 |
+
)
|
| 1662 |
+
if not items_html:
|
| 1663 |
+
return ""
|
| 1664 |
+
return (
|
| 1665 |
+
"<div class='build-column build-column-highlight'>"
|
| 1666 |
+
"<div class='build-column-title'>Most Popular Items</div>"
|
| 1667 |
+
f"<div class='build-item-list build-item-list-row'>{''.join(items_html)}</div>"
|
| 1668 |
+
"</div>"
|
| 1669 |
+
)
|
| 1670 |
+
|
| 1671 |
+
|
| 1672 |
+
def _build_columns_html(
|
| 1673 |
+
hero_id: int,
|
| 1674 |
+
build_stats: pl.DataFrame,
|
| 1675 |
+
item_metadata: dict[str, dict[str, object]],
|
| 1676 |
+
top_n: int = 8,
|
| 1677 |
+
) -> str:
|
| 1678 |
+
if build_stats.is_empty():
|
| 1679 |
+
return (
|
| 1680 |
+
"<div class='empty-strip'>No build table is available yet. "
|
| 1681 |
+
"Run match enrichment and normalization first.</div>"
|
| 1682 |
+
)
|
| 1683 |
+
filtered = build_stats.filter(
|
| 1684 |
+
(pl.col("hero_id") == hero_id) & ~pl.col("item_key").str.starts_with("recipe_")
|
| 1685 |
+
)
|
| 1686 |
+
if filtered.is_empty():
|
| 1687 |
+
return "<div class='empty-strip'>No observed item timings for that hero.</div>"
|
| 1688 |
+
columns = []
|
| 1689 |
+
for bucket in TIME_BUCKET_ORDER:
|
| 1690 |
+
bucket_rows = filtered.filter(pl.col("time_bucket") == bucket)
|
| 1691 |
+
if bucket_rows.is_empty():
|
| 1692 |
+
continue
|
| 1693 |
+
total = bucket_rows["purchases"].sum() or 0
|
| 1694 |
+
top_rows = bucket_rows.sort("purchases", descending=True).head(top_n)
|
| 1695 |
+
items_html = []
|
| 1696 |
+
for order, row in enumerate(
|
| 1697 |
+
top_rows.sort("median_time").iter_rows(named=True), start=1
|
| 1698 |
+
):
|
| 1699 |
+
item_key = str(row["item_key"])
|
| 1700 |
+
item_row = item_metadata.get(item_key, {})
|
| 1701 |
+
item_name = _html_escape(item_row.get("name") or item_key.replace("_", " ").title())
|
| 1702 |
+
icon = _html_escape(_item_icon_url(item_key))
|
| 1703 |
+
tooltip = _item_tooltip(item_row.get("name") or item_name, item_row)
|
| 1704 |
+
purchases = int(row["purchases"])
|
| 1705 |
+
share = (purchases / total * 100) if total else 0
|
| 1706 |
+
median_time = _format_item_time(row.get("median_time"))
|
| 1707 |
+
items_html.append(
|
| 1708 |
+
f"<div class='build-item' title='{tooltip}'>"
|
| 1709 |
+
f"<span class='build-item-order'>{order}</span>"
|
| 1710 |
+
f"<img src='{icon}' alt='{item_name}' loading='lazy'>"
|
| 1711 |
+
"<div class='build-item-meta'>"
|
| 1712 |
+
f"<strong>{item_name}</strong>"
|
| 1713 |
+
f"<span>{share:.0f}% of buys · median {median_time}</span>"
|
| 1714 |
+
f"<div class='build-item-bar'><span style='width: {share:.0f}%'></span></div>"
|
| 1715 |
+
"</div></div>"
|
| 1716 |
+
)
|
| 1717 |
+
columns.append(
|
| 1718 |
+
"<div class='build-column'>"
|
| 1719 |
+
"<div class='build-column-title'>"
|
| 1720 |
+
f"{_html_escape(TIME_BUCKET_LABELS.get(bucket, bucket))}</div>"
|
| 1721 |
+
f"<div class='build-item-list'>{''.join(items_html)}</div>"
|
| 1722 |
+
"</div>"
|
| 1723 |
+
)
|
| 1724 |
+
if not columns:
|
| 1725 |
+
return "<div class='empty-strip'>No observed item timings for that hero.</div>"
|
| 1726 |
+
return "<div class='build-columns'>" + "".join(columns) + "</div>"
|
| 1727 |
+
|
| 1728 |
+
|
| 1729 |
def _selected_hero_html(
|
| 1730 |
hero_ids: object, metadata: dict[int, dict[str, str]], target: str | None = None
|
| 1731 |
) -> str:
|
|
|
|
| 1829 |
hero_choices = _hero_choices(recommender.heroes)
|
| 1830 |
item_table = read_parquet(settings.parquet_dir / "dim_item.parquet")
|
| 1831 |
item_choices = _item_choices(item_table)
|
| 1832 |
+
item_metadata = _item_metadata(item_table)
|
| 1833 |
|
| 1834 |
def hero_preview(hero_ids: list[int] | None, target: str | None = None) -> str:
|
| 1835 |
return _selected_hero_html(hero_ids or [], hero_metadata, target)
|
|
|
|
| 1924 |
return prediction.get("message", "Prediction unavailable.")
|
| 1925 |
return json.dumps(prediction, indent=2)
|
| 1926 |
|
| 1927 |
+
def hero_builds(hero_id: int | None) -> str:
|
| 1928 |
hero_ids, unknown = _parse_heroes([hero_id] if hero_id else [], hero_name_lookup)
|
| 1929 |
if unknown:
|
| 1930 |
+
unknown_text = _html_escape(", ".join(unknown))
|
| 1931 |
+
return f"<div class='empty-strip'>Unrecognized hero: {unknown_text}</div>"
|
| 1932 |
if not hero_ids:
|
| 1933 |
+
return "<div class='empty-strip'>Select a hero.</div>"
|
| 1934 |
+
header = _build_hero_header_html(hero_ids[0], hero_metadata)
|
| 1935 |
+
top_build = _build_top_items_html(hero_ids[0], build_stats, item_metadata)
|
| 1936 |
+
columns = _build_columns_html(hero_ids[0], build_stats, item_metadata)
|
| 1937 |
+
return header + top_build + columns
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1938 |
|
| 1939 |
def draft_lab(enemies: list[int] | None, role: str, twist: str) -> str:
|
| 1940 |
enemy_ids, unknown = _parse_heroes(enemies, hero_name_lookup)
|
|
|
|
| 1974 |
"- Rule: if the evidence is thin, say so before giving the pick."
|
| 1975 |
)
|
| 1976 |
|
|
|
|
|
|
|
|
|
|
| 1977 |
with gr.Blocks(title="DOTA2Tuned") as demo:
|
| 1978 |
with gr.Sidebar(open=True, width=238, position="left", elem_classes=["app-sidebar"]):
|
| 1979 |
gr.HTML(_sidebar_brand_html())
|
| 1980 |
with gr.Column(elem_classes=["app-sidebar-nav-section"]):
|
| 1981 |
+
gr.Radio(
|
| 1982 |
choices=NAV_OPTIONS,
|
| 1983 |
value="Draft Coach",
|
| 1984 |
show_label=False,
|
| 1985 |
container=False,
|
| 1986 |
+
# Force interactive: the nav drives view switching purely in JS
|
| 1987 |
+
# (no server event), so without this Gradio renders it disabled.
|
| 1988 |
+
interactive=True,
|
| 1989 |
elem_classes=["app-nav"],
|
| 1990 |
)
|
| 1991 |
with gr.Column(elem_classes=["app-main"]):
|
| 1992 |
with gr.Column(
|
| 1993 |
+
visible=True, elem_id="draft-coach-view", elem_classes=["app-view", "d2-active"]
|
| 1994 |
+
):
|
| 1995 |
with gr.Row():
|
| 1996 |
allies = gr.Dropdown(
|
| 1997 |
choices=hero_choices,
|
|
|
|
| 2090 |
)
|
| 2091 |
|
| 2092 |
with gr.Column(
|
| 2093 |
+
visible=True, elem_id="hero-meta-view", elem_classes=["app-view"]
|
| 2094 |
+
):
|
| 2095 |
with gr.Row():
|
| 2096 |
meta_hero = gr.Dropdown(
|
| 2097 |
choices=hero_choices,
|
|
|
|
| 2130 |
)
|
| 2131 |
|
| 2132 |
with gr.Column(
|
| 2133 |
+
visible=True, elem_id="tuned-model-view", elem_classes=["app-view"]
|
| 2134 |
+
):
|
| 2135 |
tuned_question = gr.Textbox(
|
| 2136 |
label="Question",
|
| 2137 |
value=(
|
|
|
|
| 2161 |
)
|
| 2162 |
|
| 2163 |
with gr.Column(
|
| 2164 |
+
visible=True, elem_id="match-predictor-view", elem_classes=["app-view"]
|
| 2165 |
+
):
|
| 2166 |
with gr.Row():
|
| 2167 |
radiant = gr.Dropdown(
|
| 2168 |
choices=hero_choices,
|
|
|
|
| 2212 |
)
|
| 2213 |
|
| 2214 |
with gr.Column(
|
| 2215 |
+
visible=True, elem_id="builds-view", elem_classes=["app-view"]
|
| 2216 |
+
):
|
| 2217 |
+
hero = gr.Dropdown(
|
| 2218 |
+
choices=hero_choices,
|
| 2219 |
+
label="Hero",
|
| 2220 |
+
value=1,
|
| 2221 |
+
filterable=True,
|
| 2222 |
+
elem_classes=["dota-dropdown", "hero-dropdown"],
|
| 2223 |
+
)
|
| 2224 |
+
build_hero_preview = gr.HTML(hero_preview([1]))
|
| 2225 |
+
builds_output = gr.HTML(hero_builds(1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2226 |
hero.change(
|
| 2227 |
hero_single_preview,
|
| 2228 |
inputs=[hero],
|
| 2229 |
outputs=[build_hero_preview],
|
| 2230 |
api_visibility="private",
|
| 2231 |
)
|
| 2232 |
+
hero.change(hero_builds, inputs=[hero], outputs=[builds_output])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2233 |
|
| 2234 |
with gr.Column(
|
| 2235 |
+
visible=True, elem_id="draft-lab-view", elem_classes=["app-view"]
|
| 2236 |
+
):
|
| 2237 |
lab_enemies = gr.Dropdown(
|
| 2238 |
choices=hero_choices,
|
| 2239 |
label="Enemy heroes",
|
|
|
|
| 2272 |
)
|
| 2273 |
|
| 2274 |
with gr.Column(
|
| 2275 |
+
visible=True, elem_id="data-freshness-view", elem_classes=["app-view"]
|
| 2276 |
+
):
|
| 2277 |
status_button = gr.Button("Refresh")
|
| 2278 |
status_output = gr.Markdown()
|
| 2279 |
status_button.click(data_status, outputs=[status_output])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2280 |
|
| 2281 |
+
# Gradio 6.18 runs client JS only via the load EVENT — launch(js=) and
|
| 2282 |
+
# Blocks(js=) do not fire on page load. Attach the script here so dropdown
|
| 2283 |
+
# icons, sidebar nav switching, and hero-card removal actually work.
|
| 2284 |
+
demo.load(
|
| 2285 |
+
None,
|
| 2286 |
+
None,
|
| 2287 |
+
None,
|
| 2288 |
+
js=_dropdown_js(_dropdown_icon_by_label(hero_choices, hero_metadata)),
|
| 2289 |
+
)
|
| 2290 |
return demo
|
tests/test_gradio_app.py
CHANGED
|
@@ -2,6 +2,7 @@ import polars as pl
|
|
| 2 |
|
| 3 |
from dota2tuned.ui.gradio_app import (
|
| 4 |
APP_CSS,
|
|
|
|
| 5 |
_dropdown_js,
|
| 6 |
_format_item_time,
|
| 7 |
_hero_aliases,
|
|
@@ -132,3 +133,61 @@ def test_parse_heroes_reports_unknown_names():
|
|
| 132 |
def test_format_item_time_labels_pre_game_buys():
|
| 133 |
assert _format_item_time(-90) == "pre-game"
|
| 134 |
assert _format_item_time(180) == "3.0 min"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from dota2tuned.ui.gradio_app import (
|
| 4 |
APP_CSS,
|
| 5 |
+
APP_HEAD,
|
| 6 |
_dropdown_js,
|
| 7 |
_format_item_time,
|
| 8 |
_hero_aliases,
|
|
|
|
| 133 |
def test_format_item_time_labels_pre_game_buys():
|
| 134 |
assert _format_item_time(-90) == "pre-game"
|
| 135 |
assert _format_item_time(180) == "3.0 min"
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_css_has_dark_accessibility_and_perf_hardening():
|
| 139 |
+
# Palette is a single source of truth; app is dark-only and AA-targeted.
|
| 140 |
+
assert ":root {" in APP_CSS
|
| 141 |
+
assert "--d2-fg" in APP_CSS
|
| 142 |
+
# WCAG 2.2: visible focus + reduced motion.
|
| 143 |
+
assert ":focus-visible" in APP_CSS
|
| 144 |
+
assert "prefers-reduced-motion" in APP_CSS
|
| 145 |
+
# Dark scrollbar is styled for both engines.
|
| 146 |
+
assert "scrollbar-color:" in APP_CSS
|
| 147 |
+
assert "::-webkit-scrollbar-thumb" in APP_CSS
|
| 148 |
+
# Perf: webfonts no longer block first paint; no whole-tree font recalc.
|
| 149 |
+
assert "font-display: swap" in APP_CSS
|
| 150 |
+
assert ".gradio-container * {" not in APP_CSS
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_dropdown_js_decorates_without_deleting_svelte_nodes():
|
| 154 |
+
js = _dropdown_js(
|
| 155 |
+
{"Anti-Mage · Carry": {"src": "https://example.test/am.png", "kind": "hero"}}
|
| 156 |
+
)
|
| 157 |
+
# Root cause of the red Error toast: we used to wipe Gradio/Svelte-owned nodes.
|
| 158 |
+
assert 'option.textContent = ""' not in js
|
| 159 |
+
assert "dota-decorated-rich" in js
|
| 160 |
+
assert "decorateOption" in js
|
| 161 |
+
# Stale Gradio-5 selectors removed.
|
| 162 |
+
assert "svelte-select-list" not in js
|
| 163 |
+
# Perf: debounced observer + throttled scroll; no blanket keyup re-decorate.
|
| 164 |
+
assert "requestAnimationFrame" in js
|
| 165 |
+
assert "scheduleDecorate" in js
|
| 166 |
+
assert '"keyup"' not in js
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_app_head_preconnects_without_theme_redirect():
|
| 170 |
+
assert "preconnect" in APP_HEAD
|
| 171 |
+
assert "cdn.steamstatic.com" in APP_HEAD
|
| 172 |
+
# Dark mode is a single CSS palette now; no ?__theme=dark URL redirect.
|
| 173 |
+
assert "__theme" not in APP_HEAD
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_css_forces_single_dark_palette():
|
| 177 |
+
# Gradio's semantic theme vars are pinned to dark values unconditionally.
|
| 178 |
+
assert "color-scheme: dark" in APP_CSS
|
| 179 |
+
assert "--background-fill-primary: var(--neutral-950)" in APP_CSS
|
| 180 |
+
assert "--body-text-color: var(--neutral-100)" in APP_CSS
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_sidebar_js_toggles_view_class_without_inline_display():
|
| 184 |
+
js = _dropdown_js({})
|
| 185 |
+
# Views are always rendered; JS toggles the .d2-active class (CSS hides the
|
| 186 |
+
# rest). It must NOT set inline display/hidden, which fought Svelte and left
|
| 187 |
+
# a freshly-selected tab blank until it was re-selected.
|
| 188 |
+
assert "view.style.display" not in js
|
| 189 |
+
assert "view.hidden" not in js
|
| 190 |
+
assert 'classList.toggle("d2-active"' in js
|
| 191 |
+
assert 'setAttribute("aria-hidden"' in js
|
| 192 |
+
# CSS owns hiding the inactive views.
|
| 193 |
+
assert ".app-view:not(.d2-active)" in APP_CSS
|
tests/test_recommend.py
CHANGED
|
@@ -126,3 +126,36 @@ def test_recommendations_use_player_match_samples_for_confidence(tmp_path: Path)
|
|
| 126 |
assert recs[0].sample_size == 150
|
| 127 |
assert recs[0].confidence == "medium"
|
| 128 |
assert "normalized player matches" in recs[0].sources
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
assert recs[0].sample_size == 150
|
| 127 |
assert recs[0].confidence == "medium"
|
| 128 |
assert "normalized player matches" in recs[0].sources
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_pair_lift_matches_filter_aggregate_semantics(tmp_path: Path):
|
| 132 |
+
write_parquet(
|
| 133 |
+
tmp_path / "dim_hero.parquet",
|
| 134 |
+
[{"hero_id": 1, "hero_name": "Anti-Mage", "roles": "Carry", "pro_pick": 10, "pro_win": 6}],
|
| 135 |
+
)
|
| 136 |
+
write_parquet(
|
| 137 |
+
tmp_path / "fact_hero_pair_stats.parquet",
|
| 138 |
+
[
|
| 139 |
+
{"hero_id": 1, "other_hero_id": 9, "relation": "enemy", "win_rate": 0.6, "games": 100},
|
| 140 |
+
{"hero_id": 1, "other_hero_id": 8, "relation": "enemy", "win_rate": 0.4, "games": 50},
|
| 141 |
+
{"hero_id": 1, "other_hero_id": 7, "relation": "ally", "win_rate": 0.7, "games": 30},
|
| 142 |
+
],
|
| 143 |
+
)
|
| 144 |
+
rec = DraftRecommender(tmp_path)
|
| 145 |
+
|
| 146 |
+
# mean(0.6, 0.4) = 0.5 -> (0.5 - 0.5) * 0.25 = 0.0 ; games summed across matches
|
| 147 |
+
assert rec._pair_lift(1, [9, 8], "enemy") == (0.0, 150)
|
| 148 |
+
# single match: (0.6 - 0.5) * 0.25 = 0.025
|
| 149 |
+
lift, games = rec._pair_lift(1, [9], "enemy")
|
| 150 |
+
assert games == 100
|
| 151 |
+
assert round(lift, 6) == 0.025
|
| 152 |
+
# ally relation is indexed separately from enemy
|
| 153 |
+
lift, games = rec._pair_lift(1, [7], "ally")
|
| 154 |
+
assert games == 30
|
| 155 |
+
assert round(lift, 6) == 0.05
|
| 156 |
+
# duplicates behave like is_in membership, not double counting
|
| 157 |
+
assert rec._pair_lift(1, [9, 9], "enemy") == rec._pair_lift(1, [9], "enemy")
|
| 158 |
+
# misses, empty input, and wrong relation all yield (0.0, 0)
|
| 159 |
+
assert rec._pair_lift(1, [999], "enemy") == (0.0, 0)
|
| 160 |
+
assert rec._pair_lift(1, [], "enemy") == (0.0, 0)
|
| 161 |
+
assert rec._pair_lift(1, [7], "enemy") == (0.0, 0)
|
uv.lock
CHANGED
|
@@ -285,7 +285,7 @@ name = "cuda-bindings"
|
|
| 285 |
version = "13.3.1"
|
| 286 |
source = { registry = "https://pypi.org/simple" }
|
| 287 |
dependencies = [
|
| 288 |
-
{ name = "cuda-pathfinder"
|
| 289 |
]
|
| 290 |
wheels = [
|
| 291 |
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
|
@@ -312,34 +312,34 @@ wheels = [
|
|
| 312 |
|
| 313 |
[package.optional-dependencies]
|
| 314 |
cudart = [
|
| 315 |
-
{ name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" },
|
| 316 |
]
|
| 317 |
cufft = [
|
| 318 |
-
{ name = "nvidia-cufft", marker = "sys_platform == 'linux'" },
|
| 319 |
]
|
| 320 |
cufile = [
|
| 321 |
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
|
| 322 |
]
|
| 323 |
cupti = [
|
| 324 |
-
{ name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" },
|
| 325 |
]
|
| 326 |
curand = [
|
| 327 |
-
{ name = "nvidia-curand", marker = "sys_platform == 'linux'" },
|
| 328 |
]
|
| 329 |
cusolver = [
|
| 330 |
-
{ name = "nvidia-cusolver", marker = "sys_platform == 'linux'" },
|
| 331 |
]
|
| 332 |
cusparse = [
|
| 333 |
-
{ name = "nvidia-cusparse", marker = "sys_platform == 'linux'" },
|
| 334 |
]
|
| 335 |
nvjitlink = [
|
| 336 |
-
{ name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" },
|
| 337 |
]
|
| 338 |
nvrtc = [
|
| 339 |
-
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" },
|
| 340 |
]
|
| 341 |
nvtx = [
|
| 342 |
-
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux'" },
|
| 343 |
]
|
| 344 |
|
| 345 |
[[package]]
|
|
@@ -438,31 +438,31 @@ train = [
|
|
| 438 |
|
| 439 |
[package.metadata]
|
| 440 |
requires-dist = [
|
| 441 |
-
{ name = "accelerate", marker = "extra == 'train'", specifier = ">=1.
|
| 442 |
-
{ name = "bitsandbytes", marker = "extra == 'train'", specifier = ">=0.
|
| 443 |
-
{ name = "datasets", specifier = ">=
|
| 444 |
-
{ name = "duckdb", specifier = ">=1.
|
| 445 |
-
{ name = "fastapi", extras = ["standard"], marker = "extra == 'modal'", specifier = ">=0.
|
| 446 |
-
{ name = "gradio", specifier = ">=
|
| 447 |
-
{ name = "httpx", specifier = ">=0.
|
| 448 |
-
{ name = "huggingface-hub", specifier = ">=1.
|
| 449 |
-
{ name = "joblib", specifier = ">=1.
|
| 450 |
-
{ name = "lightgbm", marker = "extra == 'lightgbm'", specifier = ">=4.
|
| 451 |
{ name = "modal", marker = "extra == 'modal'", specifier = ">=1.5.0" },
|
| 452 |
-
{ name = "numpy", specifier = ">=
|
| 453 |
-
{ name = "peft", marker = "extra == 'train'", specifier = ">=0.
|
| 454 |
-
{ name = "polars", specifier = ">=1.
|
| 455 |
-
{ name = "pyarrow", specifier = ">=
|
| 456 |
-
{ name = "pydantic", specifier = ">=2.
|
| 457 |
-
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=
|
| 458 |
-
{ name = "python-dotenv", specifier = ">=1.
|
| 459 |
-
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.
|
| 460 |
-
{ name = "scikit-learn", specifier = ">=1.
|
| 461 |
-
{ name = "tenacity", specifier = ">=
|
| 462 |
-
{ name = "torch", marker = "extra == 'train'", specifier = ">=2.
|
| 463 |
-
{ name = "transformers", marker = "extra == 'train'", specifier = ">=
|
| 464 |
-
{ name = "trl", marker = "extra == 'train'", specifier = ">=
|
| 465 |
-
{ name = "typer", specifier = ">=0.
|
| 466 |
]
|
| 467 |
provides-extras = ["train", "lightgbm", "modal", "dev"]
|
| 468 |
|
|
@@ -503,7 +503,7 @@ wheels = [
|
|
| 503 |
|
| 504 |
[[package]]
|
| 505 |
name = "fastapi"
|
| 506 |
-
version = "0.
|
| 507 |
source = { registry = "https://pypi.org/simple" }
|
| 508 |
dependencies = [
|
| 509 |
{ name = "annotated-doc" },
|
|
@@ -512,9 +512,9 @@ dependencies = [
|
|
| 512 |
{ name = "typing-extensions" },
|
| 513 |
{ name = "typing-inspection" },
|
| 514 |
]
|
| 515 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 516 |
wheels = [
|
| 517 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 518 |
]
|
| 519 |
|
| 520 |
[package.optional-dependencies]
|
|
@@ -1166,7 +1166,7 @@ name = "nvidia-cublas"
|
|
| 1166 |
version = "13.1.1.3"
|
| 1167 |
source = { registry = "https://pypi.org/simple" }
|
| 1168 |
dependencies = [
|
| 1169 |
-
{ name = "nvidia-cuda-nvrtc"
|
| 1170 |
]
|
| 1171 |
wheels = [
|
| 1172 |
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
|
@@ -1205,7 +1205,7 @@ name = "nvidia-cudnn-cu13"
|
|
| 1205 |
version = "9.20.0.48"
|
| 1206 |
source = { registry = "https://pypi.org/simple" }
|
| 1207 |
dependencies = [
|
| 1208 |
-
{ name = "nvidia-cublas"
|
| 1209 |
]
|
| 1210 |
wheels = [
|
| 1211 |
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
|
@@ -1217,7 +1217,7 @@ name = "nvidia-cufft"
|
|
| 1217 |
version = "12.0.0.61"
|
| 1218 |
source = { registry = "https://pypi.org/simple" }
|
| 1219 |
dependencies = [
|
| 1220 |
-
{ name = "nvidia-nvjitlink"
|
| 1221 |
]
|
| 1222 |
wheels = [
|
| 1223 |
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
|
@@ -1247,9 +1247,9 @@ name = "nvidia-cusolver"
|
|
| 1247 |
version = "12.0.4.66"
|
| 1248 |
source = { registry = "https://pypi.org/simple" }
|
| 1249 |
dependencies = [
|
| 1250 |
-
{ name = "nvidia-cublas"
|
| 1251 |
-
{ name = "nvidia-cusparse"
|
| 1252 |
-
{ name = "nvidia-nvjitlink"
|
| 1253 |
]
|
| 1254 |
wheels = [
|
| 1255 |
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
|
@@ -1261,7 +1261,7 @@ name = "nvidia-cusparse"
|
|
| 1261 |
version = "12.6.3.3"
|
| 1262 |
source = { registry = "https://pypi.org/simple" }
|
| 1263 |
dependencies = [
|
| 1264 |
-
{ name = "nvidia-nvjitlink"
|
| 1265 |
]
|
| 1266 |
wheels = [
|
| 1267 |
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
|
|
|
| 285 |
version = "13.3.1"
|
| 286 |
source = { registry = "https://pypi.org/simple" }
|
| 287 |
dependencies = [
|
| 288 |
+
{ name = "cuda-pathfinder" },
|
| 289 |
]
|
| 290 |
wheels = [
|
| 291 |
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
|
|
|
| 312 |
|
| 313 |
[package.optional-dependencies]
|
| 314 |
cudart = [
|
| 315 |
+
{ name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 316 |
]
|
| 317 |
cufft = [
|
| 318 |
+
{ name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 319 |
]
|
| 320 |
cufile = [
|
| 321 |
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
|
| 322 |
]
|
| 323 |
cupti = [
|
| 324 |
+
{ name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 325 |
]
|
| 326 |
curand = [
|
| 327 |
+
{ name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 328 |
]
|
| 329 |
cusolver = [
|
| 330 |
+
{ name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 331 |
]
|
| 332 |
cusparse = [
|
| 333 |
+
{ name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 334 |
]
|
| 335 |
nvjitlink = [
|
| 336 |
+
{ name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 337 |
]
|
| 338 |
nvrtc = [
|
| 339 |
+
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 340 |
]
|
| 341 |
nvtx = [
|
| 342 |
+
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
| 343 |
]
|
| 344 |
|
| 345 |
[[package]]
|
|
|
|
| 438 |
|
| 439 |
[package.metadata]
|
| 440 |
requires-dist = [
|
| 441 |
+
{ name = "accelerate", marker = "extra == 'train'", specifier = ">=1.14.0" },
|
| 442 |
+
{ name = "bitsandbytes", marker = "extra == 'train'", specifier = ">=0.49.2" },
|
| 443 |
+
{ name = "datasets", specifier = ">=5.0.0" },
|
| 444 |
+
{ name = "duckdb", specifier = ">=1.5.3" },
|
| 445 |
+
{ name = "fastapi", extras = ["standard"], marker = "extra == 'modal'", specifier = ">=0.137.0" },
|
| 446 |
+
{ name = "gradio", specifier = ">=6.18.0" },
|
| 447 |
+
{ name = "httpx", specifier = ">=0.28.1" },
|
| 448 |
+
{ name = "huggingface-hub", specifier = ">=1.19.0" },
|
| 449 |
+
{ name = "joblib", specifier = ">=1.5.3" },
|
| 450 |
+
{ name = "lightgbm", marker = "extra == 'lightgbm'", specifier = ">=4.6.0" },
|
| 451 |
{ name = "modal", marker = "extra == 'modal'", specifier = ">=1.5.0" },
|
| 452 |
+
{ name = "numpy", specifier = ">=2.4.6" },
|
| 453 |
+
{ name = "peft", marker = "extra == 'train'", specifier = ">=0.19.1" },
|
| 454 |
+
{ name = "polars", specifier = ">=1.41.2" },
|
| 455 |
+
{ name = "pyarrow", specifier = ">=24.0.0" },
|
| 456 |
+
{ name = "pydantic", specifier = ">=2.13.4" },
|
| 457 |
+
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.1.0" },
|
| 458 |
+
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
| 459 |
+
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.17" },
|
| 460 |
+
{ name = "scikit-learn", specifier = ">=1.9.0" },
|
| 461 |
+
{ name = "tenacity", specifier = ">=9.1.4" },
|
| 462 |
+
{ name = "torch", marker = "extra == 'train'", specifier = ">=2.12.0" },
|
| 463 |
+
{ name = "transformers", marker = "extra == 'train'", specifier = ">=5.12.0" },
|
| 464 |
+
{ name = "trl", marker = "extra == 'train'", specifier = ">=1.6.0" },
|
| 465 |
+
{ name = "typer", specifier = ">=0.25.1" },
|
| 466 |
]
|
| 467 |
provides-extras = ["train", "lightgbm", "modal", "dev"]
|
| 468 |
|
|
|
|
| 503 |
|
| 504 |
[[package]]
|
| 505 |
name = "fastapi"
|
| 506 |
+
version = "0.137.0"
|
| 507 |
source = { registry = "https://pypi.org/simple" }
|
| 508 |
dependencies = [
|
| 509 |
{ name = "annotated-doc" },
|
|
|
|
| 512 |
{ name = "typing-extensions" },
|
| 513 |
{ name = "typing-inspection" },
|
| 514 |
]
|
| 515 |
+
sdist = { url = "https://files.pythonhosted.org/packages/da/fe/fb25c287ff7e0f79fc6acf2e8b812725dad28d2a1446c0410bab1422ac90/fastapi-0.137.0.tar.gz", hash = "sha256:d0565d551f65a803ecff245390840867186f456ef98971f750724eed16e1541c", size = 408023, upload-time = "2026-06-14T12:51:30.672Z" }
|
| 516 |
wheels = [
|
| 517 |
+
{ url = "https://files.pythonhosted.org/packages/e7/f1/b38481428e50131e5345b535414d11d196f14990122fe69c9020c64e5683/fastapi-0.137.0-py3-none-any.whl", hash = "sha256:6dcbde8d464f92117c1accb9e42720f8e423fa9b86cb563b1f5862f785a06498", size = 121777, upload-time = "2026-06-14T12:51:29.067Z" },
|
| 518 |
]
|
| 519 |
|
| 520 |
[package.optional-dependencies]
|
|
|
|
| 1166 |
version = "13.1.1.3"
|
| 1167 |
source = { registry = "https://pypi.org/simple" }
|
| 1168 |
dependencies = [
|
| 1169 |
+
{ name = "nvidia-cuda-nvrtc" },
|
| 1170 |
]
|
| 1171 |
wheels = [
|
| 1172 |
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
|
|
|
| 1205 |
version = "9.20.0.48"
|
| 1206 |
source = { registry = "https://pypi.org/simple" }
|
| 1207 |
dependencies = [
|
| 1208 |
+
{ name = "nvidia-cublas" },
|
| 1209 |
]
|
| 1210 |
wheels = [
|
| 1211 |
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
|
|
|
| 1217 |
version = "12.0.0.61"
|
| 1218 |
source = { registry = "https://pypi.org/simple" }
|
| 1219 |
dependencies = [
|
| 1220 |
+
{ name = "nvidia-nvjitlink" },
|
| 1221 |
]
|
| 1222 |
wheels = [
|
| 1223 |
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
|
|
|
| 1247 |
version = "12.0.4.66"
|
| 1248 |
source = { registry = "https://pypi.org/simple" }
|
| 1249 |
dependencies = [
|
| 1250 |
+
{ name = "nvidia-cublas" },
|
| 1251 |
+
{ name = "nvidia-cusparse" },
|
| 1252 |
+
{ name = "nvidia-nvjitlink" },
|
| 1253 |
]
|
| 1254 |
wheels = [
|
| 1255 |
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
|
|
|
| 1261 |
version = "12.6.3.3"
|
| 1262 |
source = { registry = "https://pypi.org/simple" }
|
| 1263 |
dependencies = [
|
| 1264 |
+
{ name = "nvidia-nvjitlink" },
|
| 1265 |
]
|
| 1266 |
wheels = [
|
| 1267 |
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|