v0.5.71: matches tab, streamer viewport video load, matchup cross-check.
Add pro watchlist matches page; load streamer clips by viewport tier with posters; harden STRATZ matchup refresh and OpenDota cross hints. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+115
-35
@@ -1,13 +1,14 @@
|
||||
"""Audit qualitative relations.json against OpenDota + STRATZ.
|
||||
|
||||
Sources:
|
||||
- data/matchups.json OpenDota vs winrates
|
||||
- data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
|
||||
- data/synergies.json STRATZ teammate synergies
|
||||
- shared/data/matchups.json OpenDota vs winrates
|
||||
- shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
|
||||
- shared/data/synergies.json STRATZ teammate synergies
|
||||
- web/data/stratz_matchup_tops.json Web 对位 Top (optional cross-check section)
|
||||
|
||||
Usage:
|
||||
python audit_relations.py
|
||||
python audit_relations.py --min-games 80 --out data/relations_audit.json
|
||||
python shared/audit_relations.py
|
||||
python shared/audit_relations.py --min-games 80 --out shared/data/relations_audit.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,12 +23,23 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shared.grid import hero_table
|
||||
from shared.paths import SHARED_DATA
|
||||
from shared.matchup_cross import audit_matchup_tops
|
||||
from shared.paths import DATA, ROOT, SHARED_DATA
|
||||
from shared.relations import DEFAULT_RELATIONS, load_relations
|
||||
|
||||
MATCHUPS = SHARED_DATA / "matchups.json"
|
||||
STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json"
|
||||
SYNERGIES = SHARED_DATA / "synergies.json"
|
||||
STRATZ_MATCHUP_TOPS = DATA / "stratz_matchup_tops.json"
|
||||
# Legacy monorepo-move leftovers (repo-root data/).
|
||||
_LEGACY_DATA = ROOT / "data"
|
||||
|
||||
|
||||
def _resolve_cache(*candidates: Path) -> Path:
|
||||
for p in candidates:
|
||||
if p.is_file():
|
||||
return p
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _load_json_safe(path: Path) -> dict:
|
||||
@@ -109,12 +121,18 @@ def main() -> None:
|
||||
ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
matchups_path = _resolve_cache(MATCHUPS, _LEGACY_DATA / "matchups.json")
|
||||
stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json")
|
||||
syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json")
|
||||
tops_path = _resolve_cache(STRATZ_MATCHUP_TOPS, _LEGACY_DATA / "stratz_matchup_tops.json")
|
||||
|
||||
id_to_key, key_to_id, names = load_id_maps()
|
||||
all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k])
|
||||
rel = load_relations()
|
||||
odota = _load_json_safe(MATCHUPS)
|
||||
stratz = _load_json_safe(STRATZ_MATCHUPS)
|
||||
syn_file = _load_json_safe(SYNERGIES)
|
||||
odota = _load_json_safe(matchups_path)
|
||||
stratz = _load_json_safe(stratz_path)
|
||||
syn_file = _load_json_safe(syn_path)
|
||||
tops = _load_json_safe(tops_path)
|
||||
by_o = odota.get("by_hero") or {}
|
||||
by_s = stratz.get("by_hero") or {}
|
||||
by_syn = syn_file.get("by_hero") or {}
|
||||
@@ -123,7 +141,6 @@ def main() -> None:
|
||||
counters = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("counters") or []]
|
||||
syns = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("synergies") or []]
|
||||
known_counters = {(a, b) for a, b, _ in counters}
|
||||
known_syn = {_pair_key(a, b) for a, b, _ in syns}
|
||||
|
||||
counter_ok, counter_conflict, counter_weak, counter_no_data = [], [], [], []
|
||||
for a, b, reason in counters:
|
||||
@@ -154,7 +171,7 @@ def main() -> None:
|
||||
s_bad = s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree
|
||||
if o_adv is None and s_adv is None:
|
||||
counter_no_data.append({**row, "why": "missing_both"})
|
||||
elif (o_games < args.min_games and (s_games < args.min_games or s_adv is None)):
|
||||
elif o_games < args.min_games and (s_games < args.min_games or s_adv is None):
|
||||
counter_no_data.append({**row, "why": "low_games"})
|
||||
elif o_ok or s_ok:
|
||||
sources = []
|
||||
@@ -182,13 +199,26 @@ def main() -> None:
|
||||
continue
|
||||
cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or (by_syn.get(str(bid)) or {}).get(str(aid))
|
||||
if not cell:
|
||||
syn_missing.append({"a": a, "b": b, "a_loc": names.get(a), "b_loc": names.get(b), "why": "no_stratz"})
|
||||
syn_missing.append(
|
||||
{
|
||||
"a": a,
|
||||
"b": b,
|
||||
"a_loc": names.get(a),
|
||||
"b_loc": names.get(b),
|
||||
"why": "no_stratz",
|
||||
}
|
||||
)
|
||||
continue
|
||||
score = float(cell.get("synergy") or 0)
|
||||
games = int(cell.get("games") or 0)
|
||||
row = {
|
||||
"a": a, "a_loc": names.get(a, a), "b": b, "b_loc": names.get(b, b),
|
||||
"reason": reason, "stratz_synergy": round(score, 3), "stratz_games": games,
|
||||
"a": a,
|
||||
"a_loc": names.get(a, a),
|
||||
"b": b,
|
||||
"b_loc": names.get(b, b),
|
||||
"reason": reason,
|
||||
"stratz_synergy": round(score, 3),
|
||||
"stratz_games": games,
|
||||
}
|
||||
if games < args.min_games:
|
||||
syn_missing.append({**row, "why": "low_games"})
|
||||
@@ -199,7 +229,6 @@ def main() -> None:
|
||||
else:
|
||||
syn_missing.append({**row, "why": "weak_synergy"})
|
||||
|
||||
# Cross-source suggestions: both OpenDota and STRATZ agree A counters B, missing from relations
|
||||
suggestions: list[dict] = []
|
||||
for akey in all_keys:
|
||||
aid = key_to_id[akey]
|
||||
@@ -210,19 +239,28 @@ def main() -> None:
|
||||
o_adv, o_games = odota_adv(by_o, baseline, aid, bid)
|
||||
s_adv, s_games = stratz_adv(by_s, aid, bid)
|
||||
if (
|
||||
o_adv is not None and o_games >= args.min_games and o_adv >= 0.03
|
||||
and s_adv is not None and s_games >= args.min_games and s_adv >= 2.0
|
||||
o_adv is not None
|
||||
and o_games >= args.min_games
|
||||
and o_adv >= 0.03
|
||||
and s_adv is not None
|
||||
and s_games >= args.min_games
|
||||
and s_adv >= 2.0
|
||||
):
|
||||
suggestions.append({
|
||||
"a": akey, "a_loc": names[akey],
|
||||
"b": bkey, "b_loc": names[bkey],
|
||||
"opendota_adv": round(o_adv, 4), "opendota_games": o_games,
|
||||
"stratz_adv": round(s_adv, 3), "stratz_games": s_games,
|
||||
})
|
||||
suggestions.append(
|
||||
{
|
||||
"a": akey,
|
||||
"a_loc": names[akey],
|
||||
"b": bkey,
|
||||
"b_loc": names[bkey],
|
||||
"opendota_adv": round(o_adv, 4),
|
||||
"opendota_games": o_games,
|
||||
"stratz_adv": round(s_adv, 3),
|
||||
"stratz_games": s_games,
|
||||
}
|
||||
)
|
||||
suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["opendota_adv"]))
|
||||
suggestions = suggestions[:100]
|
||||
|
||||
# Per-hero coverage in qualitative relations
|
||||
counters_from: dict[str, int] = {k: 0 for k in all_keys}
|
||||
counters_to: dict[str, int] = {k: 0 for k in all_keys}
|
||||
syn_count: dict[str, int] = {k: 0 for k in all_keys}
|
||||
@@ -249,20 +287,18 @@ def main() -> None:
|
||||
],
|
||||
}
|
||||
|
||||
# Qualitative gaps: no counter-out, no counter-in, no synergy
|
||||
no_counter_out = [k for k in all_keys if counters_from[k] == 0]
|
||||
no_counter_in = [k for k in all_keys if counters_to[k] == 0]
|
||||
no_synergy = [k for k in all_keys if syn_count[k] == 0]
|
||||
thin = [
|
||||
k for k in all_keys
|
||||
if counters_from[k] + counters_to[k] + syn_count[k] <= 1
|
||||
k for k in all_keys if counters_from[k] + counters_to[k] + syn_count[k] <= 1
|
||||
]
|
||||
empty = [
|
||||
k for k in all_keys
|
||||
k
|
||||
for k in all_keys
|
||||
if counters_from[k] == 0 and counters_to[k] == 0 and syn_count[k] == 0
|
||||
]
|
||||
|
||||
# Heroes with strong cross-source suggestions but empty/thin qualitative coverage
|
||||
suggest_by_hero: dict[str, int] = {}
|
||||
for row in suggestions:
|
||||
suggest_by_hero[row["a"]] = suggest_by_hero.get(row["a"], 0) + 1
|
||||
@@ -278,31 +314,60 @@ def main() -> None:
|
||||
}
|
||||
for k in empty + [x for x in thin if x not in empty]
|
||||
],
|
||||
key=lambda r: (r["counters_out"] + r["counters_in"] + r["synergies"], -r["cross_source_suggestions"], r["key"]),
|
||||
key=lambda r: (
|
||||
r["counters_out"] + r["counters_in"] + r["synergies"],
|
||||
-r["cross_source_suggestions"],
|
||||
r["key"],
|
||||
),
|
||||
)
|
||||
|
||||
web_tops_audit = None
|
||||
if tops.get("by_hero"):
|
||||
web_tops_audit = audit_matchup_tops(
|
||||
tops,
|
||||
odota,
|
||||
id_to_key=id_to_key,
|
||||
key_to_id=key_to_id,
|
||||
names=names,
|
||||
min_games=args.min_games,
|
||||
)
|
||||
|
||||
def _rel(path: Path) -> str | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
return str(path.relative_to(ROOT)).replace("\\", "/")
|
||||
except ValueError:
|
||||
return str(path).replace("\\", "/")
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"sources": {
|
||||
"relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
|
||||
"opendota_matchups": {
|
||||
"path": str(MATCHUPS.relative_to(ROOT)).replace("\\", "/") if MATCHUPS.is_file() else None,
|
||||
"path": _rel(matchups_path),
|
||||
"source": odota.get("source"),
|
||||
"fetched_at": odota.get("fetched_at"),
|
||||
"heroes": len(by_o),
|
||||
},
|
||||
"stratz_matchups": {
|
||||
"path": str(STRATZ_MATCHUPS.relative_to(ROOT)).replace("\\", "/") if STRATZ_MATCHUPS.is_file() else None,
|
||||
"path": _rel(stratz_path),
|
||||
"source": stratz.get("source"),
|
||||
"fetched_at": stratz.get("fetched_at"),
|
||||
"heroes": len(by_s),
|
||||
},
|
||||
"stratz_synergies": {
|
||||
"path": str(SYNERGIES.relative_to(ROOT)).replace("\\", "/") if SYNERGIES.is_file() else None,
|
||||
"path": _rel(syn_path),
|
||||
"source": syn_file.get("source"),
|
||||
"fetched_at": syn_file.get("fetched_at"),
|
||||
"heroes": len(by_syn),
|
||||
},
|
||||
"stratz_matchup_tops": {
|
||||
"path": _rel(tops_path),
|
||||
"source": tops.get("source"),
|
||||
"fetched_at": tops.get("fetched_at"),
|
||||
"heroes": len(tops.get("by_hero") or {}),
|
||||
},
|
||||
},
|
||||
"thresholds": {
|
||||
"min_games": args.min_games,
|
||||
@@ -338,13 +403,20 @@ def main() -> None:
|
||||
counter_conflict,
|
||||
key=lambda r: ((r.get("stratz_adv") or 0), (r.get("opendota_adv") or 0)),
|
||||
),
|
||||
"counter_agree_both": [r for r in counter_ok if set(r.get("agree_sources") or []) == {"opendota", "stratz"}],
|
||||
"counter_agree_both": [
|
||||
r
|
||||
for r in counter_ok
|
||||
if set(r.get("agree_sources") or []) == {"opendota", "stratz"}
|
||||
],
|
||||
"suggest_add_counters_cross_source": suggestions,
|
||||
"synergy_conflicts": syn_conflict,
|
||||
"web_matchup_tops_cross": web_tops_audit,
|
||||
}
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
args.out.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
s = report["summary"]
|
||||
print("=== relations audit ===")
|
||||
@@ -361,6 +433,14 @@ def main() -> None:
|
||||
f"qualitative gaps: empty={s['heroes_empty_relations']} thin(<={1} edge)={s['heroes_thin_relations']} "
|
||||
f"/ heroes={s['heroes_total']}"
|
||||
)
|
||||
if web_tops_audit:
|
||||
ws = web_tops_audit["summary"]
|
||||
print(
|
||||
"web matchup tops × OpenDota: "
|
||||
f"agree={ws['agree']} conflict={ws['conflict']} weak={ws['weak']} "
|
||||
f"/ rows={ws['counter_rows']} heroes={ws['heroes']}"
|
||||
)
|
||||
print(f" note: {web_tops_audit['manual_review_note']}")
|
||||
print("cache gaps:")
|
||||
for k, v in missing_cache.items():
|
||||
print(f" {k}: {len(v)}")
|
||||
|
||||
Reference in New Issue
Block a user