Dracufeuer commited on
Commit
b3cffb8
·
verified ·
1 Parent(s): ad5ba6e

fix(ui): sync merge and dependency updates

Browse files

Code-only sync from GitHub commit 5c8b9dc. Data artifacts were left unchanged on the Space to avoid reuploading runtime datasets.

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
- demo.launch(css=APP_CSS, js=getattr(demo, "dota2tuned_js", None), theme=gr.themes.Soft())
 
 
 
 
 
 
 
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>=3.0.0",
10
- "duckdb>=1.1.0",
11
- "gradio>=5.0.0",
12
- "httpx>=0.27.0",
13
- "huggingface-hub>=1.0.0",
14
- "joblib>=1.4.0",
15
- "numpy>=1.26.0",
16
- "polars>=1.0.0",
17
- "pyarrow>=16.0.0",
18
- "pydantic>=2.8.0",
19
- "python-dotenv>=1.0.0",
20
- "scikit-learn>=1.5.0",
21
- "tenacity>=8.3.0",
22
- "typer>=0.12.0",
23
  ]
24
 
25
  [project.optional-dependencies]
26
  train = [
27
- "accelerate>=1.0.0",
28
- "bitsandbytes>=0.44.0",
29
- "peft>=0.13.0",
30
- "torch>=2.4.0",
31
- "transformers>=4.51.0",
32
- "trl>=0.25.0",
33
  ]
34
- lightgbm = ["lightgbm>=4.5.0"]
35
- modal = ["modal>=1.5.0", "fastapi[standard]>=0.115.0"]
36
- dev = ["pytest>=8.3.0", "ruff>=0.6.0"]
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>=3.0.0
2
- duckdb>=1.1.0
3
- gradio>=5.0.0
4
- httpx>=0.27.0
5
- huggingface-hub>=1.0.0
6
- joblib>=1.4.0
7
  modal>=1.5.0
8
- numpy>=1.26.0
9
- polars>=1.0.0
10
- pyarrow>=16.0.0
11
- pydantic>=2.8.0
12
- python-dotenv>=1.0.0
13
- scikit-learn>=1.5.0
14
- tenacity>=8.3.0
15
- typer>=0.12.0
16
 
17
  # GPU Space / training dependencies. These are safe on GPU Spaces and HF Jobs.
18
- accelerate>=1.0.0
19
- bitsandbytes>=0.44.0
20
- peft>=0.13.0
21
- torch>=2.4.0
22
- transformers>=4.51.0
23
- trl>=0.25.0
 
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
- from dota2tuned.ui.gradio_app import APP_CSS
 
 
204
 
205
  demo = build_app()
206
- demo.launch(css=APP_CSS, js=getattr(demo, "dota2tuned_js", None))
 
 
 
 
 
 
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
- "huggingface_hub",
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
- # "huggingface_hub",
 
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.1.0",
25
- "fastapi[standard]>=0.115.0",
26
- "gradio>=5.0.0",
27
- "httpx>=0.27.0",
28
- "huggingface-hub>=1.0.0",
29
- "joblib>=1.4.0",
30
- "numpy>=1.26.0",
31
- "polars>=1.0.0",
32
- "pyarrow>=16.0.0",
33
- "pydantic>=2.8.0",
34
- "python-dotenv>=1.0.0",
35
- "scikit-learn>=1.5.0",
36
- "tenacity>=8.3.0",
37
  ]
38
 
39
  TRAIN_DEPS = [
40
  *CORE_DEPS,
41
- "accelerate>=1.0.0",
42
- "bitsandbytes>=0.44.0",
43
- "datasets>=3.0.0",
44
  "hf-transfer>=0.1.9",
45
- "peft>=0.13.0",
46
- "torch>=2.4.0",
47
- "transformers>=4.51.0",
48
- "trl>=0.25.0",
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
- js=getattr(demo, "dota2tuned_js", None),
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 self.pairs.is_empty() or not others:
132
  return 0.0, 0
133
- subset = self.pairs.filter(
134
- (pl.col("hero_id") == hero_id)
135
- & (pl.col("other_hero_id").is_in(others))
136
- & (pl.col("relation") == relation)
137
- )
138
- if subset.is_empty():
 
 
 
 
 
 
 
139
  return 0.0, 0
140
- avg = subset.select(pl.col("win_rate").mean()).item()
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 or 0)
 
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: rgba(103, 112, 123, 0.42) !important;
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: #efe5bb !important;
 
 
 
 
 
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, rgba(234, 105, 83, 0.98), rgba(126, 43, 34, 0.98)) !important;
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.18);
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: #8b929a;
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
- display: none !important;
 
 
 
 
 
 
 
 
 
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: transparent !important;
498
- color: #ff6046 !important;
 
 
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 rgba(103, 112, 123, 0.55) !important;
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
- .dota-dropdown [role="option"],
545
- .dota-dropdown .option,
546
- .dota-dropdown .dropdown-option,
547
- .dota-dropdown .svelte-select-list div {
548
- color: #dcdedf !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.34) !important;
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: #8b929a;
754
  font-size: 13px;
755
  padding: 7px 0;
756
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
  @media (max-width: 720px) {
758
- .dota-dropdown .options[role="listbox"],
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).split(" · ", 1)[0].replace(/\\s+\\([^)]*\\)\\s*$/, "");
 
 
 
 
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 visible = name === selected;
826
- view.hidden = !visible;
827
- view.style.display = visible ? "" : "none";
828
- view.setAttribute("aria-hidden", visible ? "false" : "true");
829
  }});
830
  }};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  const decorate = () => {{
832
- const optionSelector = [
833
- '[role="option"]',
834
- '.option',
835
- '.dropdown-option',
836
- 'li',
837
- '.svelte-select-list div'
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
- decorate();
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(decorate);
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
- document.addEventListener("click", () => setTimeout(decorate, 0), true);
956
- document.addEventListener("keyup", () => setTimeout(decorate, 0), true);
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("<", "&lt;")
1089
  .replace(">", "&gt;")
1090
  .replace('"', "&quot;")
 
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, item_key: str | None) -> str:
1404
  hero_ids, unknown = _parse_heroes([hero_id] if hero_id else [], hero_name_lookup)
1405
  if unknown:
1406
- return f"Unrecognized hero: {', '.join(unknown)}"
 
1407
  if not hero_ids:
1408
- return "Select a hero."
1409
- if build_stats.is_empty():
1410
- return "No build table is available yet. Run match enrichment and normalization first."
1411
- filtered = build_stats.filter(pl.col("hero_id") == hero_ids[0])
1412
- if item_key:
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
- nav = gr.Radio(
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
- ) as draft_view:
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=False, elem_id="hero-meta-view", elem_classes=["app-view"]
1579
- ) as hero_meta_view:
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=False, elem_id="tuned-model-view", elem_classes=["app-view"]
1619
- ) as tuned_model_view:
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=False, elem_id="match-predictor-view", elem_classes=["app-view"]
1650
- ) as match_predictor_view:
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=False, elem_id="builds-view", elem_classes=["app-view"]
1701
- ) as builds_view:
1702
- with gr.Row():
1703
- hero = gr.Dropdown(
1704
- choices=hero_choices,
1705
- label="Hero",
1706
- value=1,
1707
- filterable=True,
1708
- elem_classes=["dota-dropdown", "hero-dropdown"],
1709
- )
1710
- build_item = gr.Dropdown(
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
- build_item.change(
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=False, elem_id="draft-lab-view", elem_classes=["app-view"]
1737
- ) as draft_lab_view:
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=False, elem_id="data-freshness-view", elem_classes=["app-view"]
1777
- ) as data_freshness_view:
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
- demo.css = APP_CSS
1797
- demo.dota2tuned_js = _dropdown_js(_dropdown_icon_by_label(hero_choices, hero_metadata))
1798
- demo.js = demo.dota2tuned_js
 
 
 
 
 
 
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("<", "&lt;")
1441
  .replace(">", "&gt;")
1442
  .replace('"', "&quot;")
1443
+ .replace("'", "&#39;")
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 &middot; 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", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
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.0.0" },
442
- { name = "bitsandbytes", marker = "extra == 'train'", specifier = ">=0.44.0" },
443
- { name = "datasets", specifier = ">=3.0.0" },
444
- { name = "duckdb", specifier = ">=1.1.0" },
445
- { name = "fastapi", extras = ["standard"], marker = "extra == 'modal'", specifier = ">=0.115.0" },
446
- { name = "gradio", specifier = ">=5.0.0" },
447
- { name = "httpx", specifier = ">=0.27.0" },
448
- { name = "huggingface-hub", specifier = ">=1.0.0" },
449
- { name = "joblib", specifier = ">=1.4.0" },
450
- { name = "lightgbm", marker = "extra == 'lightgbm'", specifier = ">=4.5.0" },
451
  { name = "modal", marker = "extra == 'modal'", specifier = ">=1.5.0" },
452
- { name = "numpy", specifier = ">=1.26.0" },
453
- { name = "peft", marker = "extra == 'train'", specifier = ">=0.13.0" },
454
- { name = "polars", specifier = ">=1.0.0" },
455
- { name = "pyarrow", specifier = ">=16.0.0" },
456
- { name = "pydantic", specifier = ">=2.8.0" },
457
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
458
- { name = "python-dotenv", specifier = ">=1.0.0" },
459
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" },
460
- { name = "scikit-learn", specifier = ">=1.5.0" },
461
- { name = "tenacity", specifier = ">=8.3.0" },
462
- { name = "torch", marker = "extra == 'train'", specifier = ">=2.4.0" },
463
- { name = "transformers", marker = "extra == 'train'", specifier = ">=4.51.0" },
464
- { name = "trl", marker = "extra == 'train'", specifier = ">=0.25.0" },
465
- { name = "typer", specifier = ">=0.12.0" },
466
  ]
467
  provides-extras = ["train", "lightgbm", "modal", "dev"]
468
 
@@ -503,7 +503,7 @@ wheels = [
503
 
504
  [[package]]
505
  name = "fastapi"
506
- version = "0.136.3"
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/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
516
  wheels = [
517
- { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
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", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
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", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
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", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
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", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1251
- { name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
1252
- { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
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", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
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" },