Spaces:
Sleeping
Sleeping
Sync demo recommendation hardening
Browse files- .gitignore +1 -0
- PLAN.md +1 -0
- SUBMISSION.md +1 -0
- scripts/check_public_space.py +80 -0
- src/dota2tuned/recommend.py +39 -2
- src/dota2tuned/ui/gradio_app.py +9 -2
- tests/test_gradio_app.py +6 -1
- tests/test_recommend.py +60 -0
.gitignore
CHANGED
|
@@ -35,6 +35,7 @@ data/*.duckdb.tmp
|
|
| 35 |
|
| 36 |
outputs/
|
| 37 |
runs/
|
|
|
|
| 38 |
*.log
|
| 39 |
|
| 40 |
.DS_Store
|
|
|
|
| 35 |
|
| 36 |
outputs/
|
| 37 |
runs/
|
| 38 |
+
submission_evidence/
|
| 39 |
*.log
|
| 40 |
|
| 41 |
.DS_Store
|
PLAN.md
CHANGED
|
@@ -111,6 +111,7 @@ All times are `America/Los_Angeles` / PDT unless noted.
|
|
| 111 |
- 2026-06-14 01:13: updated this plan with the execution timeline. Current required finish checks are lint, tests, Gradio import smoke, commit/push, Space upload, Space HTTP 200, Modal UI HTTP 200, `modal-smoke`, and `modal-ask`.
|
| 112 |
- 2026-06-14 01:21: checked the latest hackathon requirements again and added `SUBMISSION.md` with required links, final checklist, demo video script, social post draft, and verification commands.
|
| 113 |
- 2026-06-14 01:27: addressed the submission-readiness review by adding hero-name parsing, making Draft Lab demoable, adding tests, preparing a dataset card, declaring Apache-2.0 licensing, and aligning the Modal dependency version.
|
|
|
|
| 114 |
|
| 115 |
## Adapter Eval Notes
|
| 116 |
|
|
|
|
| 111 |
- 2026-06-14 01:13: updated this plan with the execution timeline. Current required finish checks are lint, tests, Gradio import smoke, commit/push, Space upload, Space HTTP 200, Modal UI HTTP 200, `modal-smoke`, and `modal-ask`.
|
| 112 |
- 2026-06-14 01:21: checked the latest hackathon requirements again and added `SUBMISSION.md` with required links, final checklist, demo video script, social post draft, and verification commands.
|
| 113 |
- 2026-06-14 01:27: addressed the submission-readiness review by adding hero-name parsing, making Draft Lab demoable, adding tests, preparing a dataset card, declaring Apache-2.0 licensing, and aligning the Modal dependency version.
|
| 114 |
+
- 2026-06-14 02:36: continued demo-readiness hardening by adding role-aware recommendation filtering, Bayesian win-rate shrinkage for low samples, pre-game item timing labels, and a repeatable public Space API check script.
|
| 115 |
|
| 116 |
## Adapter Eval Notes
|
| 117 |
|
SUBMISSION.md
CHANGED
|
@@ -79,6 +79,7 @@ uv run pytest -q
|
|
| 79 |
uv run python -c "from app import demo; print(type(demo).__name__, len(demo.blocks), len(demo.fns))"
|
| 80 |
uv run dota2tuned modal-smoke
|
| 81 |
uv run dota2tuned modal-ask "Suggest one mid hero against Phantom Assassin and Witch Doctor. Include one caveat." --context "Use only grounded advice. Mention that sample sizes and patch context matter." --max-new-tokens 160
|
|
|
|
| 82 |
curl -L -sS -o /dev/null -w '%{http_code}\n' https://build-small-hackathon-dota2tuned.hf.space
|
| 83 |
curl -sS -o /dev/null -w '%{http_code}\n' https://dracufeuer--dota2tuned-ui.modal.run
|
| 84 |
git status --short --branch
|
|
|
|
| 79 |
uv run python -c "from app import demo; print(type(demo).__name__, len(demo.blocks), len(demo.fns))"
|
| 80 |
uv run dota2tuned modal-smoke
|
| 81 |
uv run dota2tuned modal-ask "Suggest one mid hero against Phantom Assassin and Witch Doctor. Include one caveat." --context "Use only grounded advice. Mention that sample sizes and patch context matter." --max-new-tokens 160
|
| 82 |
+
uv run python scripts/check_public_space.py
|
| 83 |
curl -L -sS -o /dev/null -w '%{http_code}\n' https://build-small-hackathon-dota2tuned.hf.space
|
| 84 |
curl -sS -o /dev/null -w '%{http_code}\n' https://dracufeuer--dota2tuned-ui.modal.run
|
| 85 |
git status --short --branch
|
scripts/check_public_space.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from datetime import UTC, datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from gradio_client import Client
|
| 10 |
+
|
| 11 |
+
DEFAULT_SPACE = "https://build-small-hackathon-dota2tuned.hf.space"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
CHECKS: list[tuple[str, tuple[Any, ...]]] = [
|
| 15 |
+
("/draft_coach", ("", "Phantom Assassin, Witch Doctor", "", "mid", "pro")),
|
| 16 |
+
("/hero_meta", ("current pro meta",)),
|
| 17 |
+
(
|
| 18 |
+
"/match_predictor",
|
| 19 |
+
("Anti-Mage, Axe, Bane, Lina, Crystal Maiden", "Pudge, Invoker, Drow Ranger, Lion, Sven"),
|
| 20 |
+
),
|
| 21 |
+
("/hero_builds", ("Anti-Mage",)),
|
| 22 |
+
("/draft_lab", ("Phantom Assassin, Witch Doctor", "mid", "Tiny scout card")),
|
| 23 |
+
("/data_status", ()),
|
| 24 |
+
(
|
| 25 |
+
"/tuned_model",
|
| 26 |
+
(
|
| 27 |
+
"Suggest one mid hero against Phantom Assassin and Witch Doctor, "
|
| 28 |
+
"and include one caveat.",
|
| 29 |
+
"",
|
| 30 |
+
160,
|
| 31 |
+
),
|
| 32 |
+
),
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run_checks(space_url: str) -> dict[str, Any]:
|
| 37 |
+
client = Client(space_url)
|
| 38 |
+
results = []
|
| 39 |
+
for endpoint, args in CHECKS:
|
| 40 |
+
try:
|
| 41 |
+
result = client.predict(*args, api_name=endpoint)
|
| 42 |
+
status = "ok"
|
| 43 |
+
except Exception as exc: # pragma: no cover - network smoke script
|
| 44 |
+
result = repr(exc)
|
| 45 |
+
status = "error"
|
| 46 |
+
results.append(
|
| 47 |
+
{
|
| 48 |
+
"endpoint": endpoint,
|
| 49 |
+
"status": status,
|
| 50 |
+
"args": args,
|
| 51 |
+
"result": result,
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
print(f"{endpoint}: {status}")
|
| 55 |
+
return {
|
| 56 |
+
"generated_at": datetime.now(UTC).isoformat(),
|
| 57 |
+
"space": space_url,
|
| 58 |
+
"checks": results,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def main() -> None:
|
| 63 |
+
parser = argparse.ArgumentParser(description="Run public DOTA2Tuned Space API checks.")
|
| 64 |
+
parser.add_argument("--space", default=DEFAULT_SPACE)
|
| 65 |
+
parser.add_argument(
|
| 66 |
+
"--output",
|
| 67 |
+
default="submission_evidence/public_space_api_checks.json",
|
| 68 |
+
help="Path for JSON evidence output.",
|
| 69 |
+
)
|
| 70 |
+
args = parser.parse_args()
|
| 71 |
+
|
| 72 |
+
payload = run_checks(args.space)
|
| 73 |
+
output_path = Path(args.output)
|
| 74 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 75 |
+
output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
| 76 |
+
print(output_path)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
src/dota2tuned/recommend.py
CHANGED
|
@@ -7,6 +7,14 @@ import polars as pl
|
|
| 7 |
from dota2tuned.schemas import DraftInput, Recommendation
|
| 8 |
from dota2tuned.storage import read_parquet
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
def _confidence(sample_size: int) -> str:
|
| 12 |
if sample_size >= 500:
|
|
@@ -16,6 +24,33 @@ def _confidence(sample_size: int) -> str:
|
|
| 16 |
return "low"
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
class DraftRecommender:
|
| 20 |
def __init__(self, parquet_dir: Path) -> None:
|
| 21 |
self.parquet_dir = parquet_dir
|
|
@@ -36,10 +71,12 @@ class DraftRecommender:
|
|
| 36 |
|
| 37 |
rows = []
|
| 38 |
for row in candidates.iter_rows(named=True):
|
|
|
|
|
|
|
| 39 |
hero_id = int(row["hero_id"])
|
| 40 |
pro_pick = int(row.get("pro_pick") or 0)
|
| 41 |
-
|
| 42 |
-
base_score =
|
| 43 |
synergy_lift = self._pair_lift(hero_id, draft.allied_heroes, "ally")
|
| 44 |
counter_lift = self._pair_lift(hero_id, draft.enemy_heroes, "enemy")
|
| 45 |
score = base_score + synergy_lift + counter_lift
|
|
|
|
| 7 |
from dota2tuned.schemas import DraftInput, Recommendation
|
| 8 |
from dota2tuned.storage import read_parquet
|
| 9 |
|
| 10 |
+
_ROLE_HINTS = {
|
| 11 |
+
"carry": {"Carry"},
|
| 12 |
+
"mid": {"Carry", "Escape", "Initiator"},
|
| 13 |
+
"offlane": {"Initiator", "Durable", "Disabler"},
|
| 14 |
+
"soft support": {"Support"},
|
| 15 |
+
"hard support": {"Support"},
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
|
| 19 |
def _confidence(sample_size: int) -> str:
|
| 20 |
if sample_size >= 500:
|
|
|
|
| 24 |
return "low"
|
| 25 |
|
| 26 |
|
| 27 |
+
def _role_tokens(value: object) -> set[str]:
|
| 28 |
+
if not value:
|
| 29 |
+
return set()
|
| 30 |
+
return {part.strip() for part in str(value).split(",") if part.strip()}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _role_match(role: str | None, roles: object) -> bool:
|
| 34 |
+
if not role:
|
| 35 |
+
return True
|
| 36 |
+
tokens = _role_tokens(roles)
|
| 37 |
+
if not tokens:
|
| 38 |
+
return True
|
| 39 |
+
role_key = role.lower()
|
| 40 |
+
hints = _ROLE_HINTS.get(role_key)
|
| 41 |
+
if not hints:
|
| 42 |
+
return True
|
| 43 |
+
if role_key == "mid" and "Support" in tokens and "Carry" not in tokens:
|
| 44 |
+
return False
|
| 45 |
+
return bool(tokens & hints)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _shrunk_win_rate(pro_win: int, pro_pick: int, *, prior_games: int = 50) -> float:
|
| 49 |
+
if pro_pick <= 0:
|
| 50 |
+
return 0.5
|
| 51 |
+
return (pro_win + (prior_games * 0.5)) / (pro_pick + prior_games)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
class DraftRecommender:
|
| 55 |
def __init__(self, parquet_dir: Path) -> None:
|
| 56 |
self.parquet_dir = parquet_dir
|
|
|
|
| 71 |
|
| 72 |
rows = []
|
| 73 |
for row in candidates.iter_rows(named=True):
|
| 74 |
+
if not _role_match(draft.role, row.get("roles")):
|
| 75 |
+
continue
|
| 76 |
hero_id = int(row["hero_id"])
|
| 77 |
pro_pick = int(row.get("pro_pick") or 0)
|
| 78 |
+
pro_win = int(row.get("pro_win") or 0)
|
| 79 |
+
base_score = _shrunk_win_rate(pro_win, pro_pick)
|
| 80 |
synergy_lift = self._pair_lift(hero_id, draft.allied_heroes, "ally")
|
| 81 |
counter_lift = self._pair_lift(hero_id, draft.enemy_heroes, "enemy")
|
| 82 |
score = base_score + synergy_lift + counter_lift
|
src/dota2tuned/ui/gradio_app.py
CHANGED
|
@@ -78,6 +78,13 @@ def _format_hero_ids(hero_ids: list[int], names: dict[int, str]) -> str:
|
|
| 78 |
return ", ".join(f"{names.get(hero_id, f'Hero {hero_id}')} ({hero_id})" for hero_id in hero_ids)
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def _format_recs(recs: list) -> str:
|
| 82 |
if not recs:
|
| 83 |
return (
|
|
@@ -209,10 +216,10 @@ def build_app() -> gr.Blocks:
|
|
| 209 |
)
|
| 210 |
lines = []
|
| 211 |
for row in rows:
|
| 212 |
-
|
| 213 |
lines.append(
|
| 214 |
f"- **{row.get('item_key')}** in `{row.get('time_bucket')}`: "
|
| 215 |
-
f"{row.get('purchases')} purchases, median `{
|
| 216 |
)
|
| 217 |
return "\n".join(lines) if lines else "No observed item timings for that hero."
|
| 218 |
|
|
|
|
| 78 |
return ", ".join(f"{names.get(hero_id, f'Hero {hero_id}')} ({hero_id})" for hero_id in hero_ids)
|
| 79 |
|
| 80 |
|
| 81 |
+
def _format_item_time(seconds: object) -> str:
|
| 82 |
+
value = float(seconds or 0)
|
| 83 |
+
if value < 0:
|
| 84 |
+
return "pre-game"
|
| 85 |
+
return f"{round(value / 60, 1)} min"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
def _format_recs(recs: list) -> str:
|
| 89 |
if not recs:
|
| 90 |
return (
|
|
|
|
| 216 |
)
|
| 217 |
lines = []
|
| 218 |
for row in rows:
|
| 219 |
+
median_time = _format_item_time(row.get("median_time"))
|
| 220 |
lines.append(
|
| 221 |
f"- **{row.get('item_key')}** in `{row.get('time_bucket')}`: "
|
| 222 |
+
f"{row.get('purchases')} purchases, median `{median_time}`"
|
| 223 |
)
|
| 224 |
return "\n".join(lines) if lines else "No observed item timings for that hero."
|
| 225 |
|
tests/test_gradio_app.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import polars as pl
|
| 2 |
|
| 3 |
-
from dota2tuned.ui.gradio_app import _hero_lookup, _parse_heroes
|
| 4 |
|
| 5 |
|
| 6 |
def test_parse_heroes_accepts_names_and_ids():
|
|
@@ -27,3 +27,8 @@ def test_parse_heroes_reports_unknown_names():
|
|
| 27 |
|
| 28 |
assert hero_ids == [1]
|
| 29 |
assert unknown == ["Banana King"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import polars as pl
|
| 2 |
|
| 3 |
+
from dota2tuned.ui.gradio_app import _format_item_time, _hero_lookup, _parse_heroes
|
| 4 |
|
| 5 |
|
| 6 |
def test_parse_heroes_accepts_names_and_ids():
|
|
|
|
| 27 |
|
| 28 |
assert hero_ids == [1]
|
| 29 |
assert unknown == ["Banana King"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_format_item_time_labels_pre_game_buys():
|
| 33 |
+
assert _format_item_time(-90) == "pre-game"
|
| 34 |
+
assert _format_item_time(180) == "3.0 min"
|
tests/test_recommend.py
CHANGED
|
@@ -12,6 +12,7 @@ def test_recommendation_schema_and_exclusions(tmp_path: Path):
|
|
| 12 |
{
|
| 13 |
"hero_id": 1,
|
| 14 |
"hero_name": "Anti-Mage",
|
|
|
|
| 15 |
"pro_pick": 1000,
|
| 16 |
"pro_win": 520,
|
| 17 |
"pro_win_rate": 0.52,
|
|
@@ -19,6 +20,7 @@ def test_recommendation_schema_and_exclusions(tmp_path: Path):
|
|
| 19 |
{
|
| 20 |
"hero_id": 2,
|
| 21 |
"hero_name": "Axe",
|
|
|
|
| 22 |
"pro_pick": 200,
|
| 23 |
"pro_win": 90,
|
| 24 |
"pro_win_rate": 0.45,
|
|
@@ -31,3 +33,61 @@ def test_recommendation_schema_and_exclusions(tmp_path: Path):
|
|
| 31 |
assert len(recs) == 1
|
| 32 |
assert isinstance(recs[0], Recommendation)
|
| 33 |
assert recs[0].hero_id == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
{
|
| 13 |
"hero_id": 1,
|
| 14 |
"hero_name": "Anti-Mage",
|
| 15 |
+
"roles": "Carry,Escape,Nuker",
|
| 16 |
"pro_pick": 1000,
|
| 17 |
"pro_win": 520,
|
| 18 |
"pro_win_rate": 0.52,
|
|
|
|
| 20 |
{
|
| 21 |
"hero_id": 2,
|
| 22 |
"hero_name": "Axe",
|
| 23 |
+
"roles": "Initiator,Durable,Disabler",
|
| 24 |
"pro_pick": 200,
|
| 25 |
"pro_win": 90,
|
| 26 |
"pro_win_rate": 0.45,
|
|
|
|
| 33 |
assert len(recs) == 1
|
| 34 |
assert isinstance(recs[0], Recommendation)
|
| 35 |
assert recs[0].hero_id == 2
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_mid_recommendations_filter_support_first_heroes(tmp_path: Path):
|
| 39 |
+
write_parquet(
|
| 40 |
+
tmp_path / "dim_hero.parquet",
|
| 41 |
+
[
|
| 42 |
+
{
|
| 43 |
+
"hero_id": 13,
|
| 44 |
+
"hero_name": "Puck",
|
| 45 |
+
"roles": "Initiator,Disabler,Escape,Nuker",
|
| 46 |
+
"pro_pick": 12,
|
| 47 |
+
"pro_win": 4,
|
| 48 |
+
"pro_win_rate": 0.3333,
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"hero_id": 91,
|
| 52 |
+
"hero_name": "Io",
|
| 53 |
+
"roles": "Support,Escape,Nuker",
|
| 54 |
+
"pro_pick": 4,
|
| 55 |
+
"pro_win": 4,
|
| 56 |
+
"pro_win_rate": 1.0,
|
| 57 |
+
},
|
| 58 |
+
],
|
| 59 |
+
)
|
| 60 |
+
write_parquet(tmp_path / "fact_hero_pair_stats.parquet", [])
|
| 61 |
+
|
| 62 |
+
recs = DraftRecommender(tmp_path).recommend(DraftInput(role="mid"), limit=5)
|
| 63 |
+
|
| 64 |
+
assert [rec.hero_name for rec in recs] == ["Puck"]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_low_sample_win_rates_are_shrunk(tmp_path: Path):
|
| 68 |
+
write_parquet(
|
| 69 |
+
tmp_path / "dim_hero.parquet",
|
| 70 |
+
[
|
| 71 |
+
{
|
| 72 |
+
"hero_id": 1,
|
| 73 |
+
"hero_name": "Tiny Sample",
|
| 74 |
+
"roles": "Carry",
|
| 75 |
+
"pro_pick": 1,
|
| 76 |
+
"pro_win": 1,
|
| 77 |
+
"pro_win_rate": 1.0,
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"hero_id": 2,
|
| 81 |
+
"hero_name": "Large Sample",
|
| 82 |
+
"roles": "Carry",
|
| 83 |
+
"pro_pick": 200,
|
| 84 |
+
"pro_win": 120,
|
| 85 |
+
"pro_win_rate": 0.6,
|
| 86 |
+
},
|
| 87 |
+
],
|
| 88 |
+
)
|
| 89 |
+
write_parquet(tmp_path / "fact_hero_pair_stats.parquet", [])
|
| 90 |
+
|
| 91 |
+
recs = DraftRecommender(tmp_path).recommend(DraftInput(role="carry"), limit=2)
|
| 92 |
+
|
| 93 |
+
assert recs[0].hero_name == "Large Sample"
|