Статистика: один проход по строкам игрока вместо четырёх

Главная гоняла тяжёлый SCORED_CTE пять раз: лидерборд, три запроса профиля (итог,
разбивка по фракциям, форма последних партий) и ещё раз итог для активной группы.
Четыре последних выбирали одни и те же строки одного игрока и отличались только
агрегацией, а стоимость CTE растёт с числом партий во всём приложении, а не в
группе игрока.

Теперь строки игрока тянутся одним запросом, а итог, разбивка и форма считаются из
них в Python. Фильтр по группе — фильтрация того же набора, поэтому блок активной
группы не стоит отдельного прохода. На главной осталось два прохода вместо пяти,
у профиля — один вместо трёх.

Формула сглаженного рейтинга получила Python-версию рядом с SQL-версией, на тех же
константах: в SQL она нужна лидерборду, где агрегация идёт по всем игрокам. Чтобы
две реализации не разъехались (как однажды вышло с кэш-бастером аватара), добавлен
тест, сверяющий цифры профиля с цифрами того же игрока в лидерборде.

Второй тест считает запросы с SCORED_CTE на главной: без него оптимизация тихо
отъедет назад при следующей правке витрин.

#32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
2026-09-09 20:38:29 +03:00
co-authored by Claude Opus 5
parent 634a4c0e08
commit e0f1ed5fee
3 changed files with 173 additions and 75 deletions
+82 -75
View File
@@ -15,6 +15,7 @@ from app.services.scoring import (
MIN_GAMES,
SMOOTHED_SCORE_SQL,
leaderboard_sort_key,
smoothed_score,
)
# Базовый блок: одна строка на участие с tie-aware очками.
@@ -106,92 +107,88 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict:
}
def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
def _user_scored_rows(session: Session, user_id: int) -> list[dict]:
"""Строки участия игрока со всеми полями, нужными витринам профиля.
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма
последних партий считаются из одного и того же набора строк. CTE джойнит участия
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с
общим числом партий, а не с числом партий игрока."""
sql = f"""
{SCORED_CTE}
SELECT COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
{SMOOTHED_SCORE_SQL} AS score
FROM scored s
{cond}
"""
params: dict[str, Any] = {"uid": user_id}
if group_id is not None:
params["gid"] = group_id
r = session.execute(text(sql), params).mappings().first() or {}
return {
"games": int(r.get("games") or 0),
"wins": int(r.get("wins") or 0),
"win_rate": _round(r.get("win_rate") or 0.0, 4),
"avg_place": _round(r.get("avg_place"), 2),
"score": _round(r.get("score"), 1),
}
def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
sql = f"""
{SCORED_CTE}
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
e.code AS expansion_code,
COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика
-- «лучшая/худшая», сглаживание задавило бы её к 50)
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at,
s.place AS place, s.player_count AS player_count,
s.points AS points, s.is_win AS is_win,
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
e.code AS expansion_code
FROM scored s
JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_id
{cond}
GROUP BY f.id, f.code, f.name_ru, e.code
ORDER BY games DESC, score DESC
WHERE s.user_id = :uid
"""
params: dict[str, Any] = {"uid": user_id}
if group_id is not None:
params["gid"] = group_id
result = session.execute(text(sql), params).mappings().all()
rows = session.execute(text(sql), {"uid": user_id}).mappings().all()
return [dict(r) for r in rows]
def _for_group(rows: list[dict], group_id: int | None) -> list[dict]:
return rows if group_id is None else [r for r in rows if r["group_id"] == group_id]
def _overall_from_rows(rows: list[dict]) -> dict:
"""Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг."""
games = len(rows)
if games == 0:
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
wins = sum(int(r["is_win"]) for r in rows)
return {
"games": games,
"wins": wins,
"win_rate": _round(wins / games, 4),
"avg_place": _round(sum(r["place"] for r in rows) / games, 2),
"score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1),
}
def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
by_faction: dict[int, list[dict]] = {}
for r in rows:
by_faction.setdefault(r["faction_id"], []).append(r)
out = []
for r in result:
for fid, group in by_faction.items():
games = len(group)
wins = sum(int(r["is_win"]) for r in group)
meta = group[0]
out.append(
{
"faction_id": r["faction_id"],
"code": r["code"],
"name_ru": r["name_ru"],
"expansion_code": r["expansion_code"],
"games": int(r["games"] or 0),
"wins": int(r["wins"] or 0),
"win_rate": _round(r["win_rate"] or 0.0, 4),
"avg_place": _round(r["avg_place"], 2),
"score": _round(r["score"], 1),
"faction_id": fid,
"code": meta["code"],
"name_ru": meta["name_ru"],
"expansion_code": meta["expansion_code"],
"games": games,
"wins": wins,
"win_rate": _round(wins / games, 4),
"avg_place": _round(sum(r["place"] for r in group) / games, 2),
# Фракции: чистое среднее (служебная метрика «лучшая/худшая»,
# сглаживание задавило бы её к 50).
"score": _round(sum(float(r["points"]) for r in group) / games * 100, 1),
"name_ru_prepositional": faction_service.prepositional(
r["code"], r["name_ru"]
meta["code"], meta["name_ru"]
),
}
)
out.sort(key=lambda f: (-f["games"], -(f["score"] or 0.0)))
return out
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
sql = f"""
{SCORED_CTE}
SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at
FROM scored s
{cond}
ORDER BY s.played_at DESC, s.match_id DESC
LIMIT :lim
"""
params: dict[str, Any] = {"uid": user_id, "lim": limit}
if group_id is not None:
params["gid"] = group_id
result = session.execute(text(sql), params).mappings().all()
def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]:
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True)
return [
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
for r in result
{
"place": r["place"],
"player_count": r["player_count"],
"played_at": str(r["played_at"]),
}
for r in recent[:limit]
]
@@ -211,9 +208,17 @@ def _favorite_faction(session: Session, user_id: int) -> dict | None:
}
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
overall = _overall_for_user(session, user_id, group_id)
factions = _faction_breakdown(session, user_id, group_id)
def profile_stats(
session: Session,
user_id: int,
group_id: int | None = None,
*,
rows: list[dict] | None = None,
) -> dict:
"""Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует)."""
scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id)
overall = _overall_from_rows(scoped)
factions = _faction_breakdown_from_rows(scoped)
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
@@ -228,7 +233,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
"worst_faction": worst,
"favorite_faction": _favorite_faction(session, user_id),
"main_faction": main,
"recent_form": _recent_form(session, user_id, group_id),
"recent_form": _recent_form_from_rows(scoped),
"min_games": MIN_GAMES,
}
@@ -460,7 +465,9 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
board = leaderboard(session, group_id=None)
profile = profile_stats(session, user_id, group_id=None)
# Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе.
rows = _user_scored_rows(session, user_id)
profile = profile_stats(session, user_id, group_id=None, rows=rows)
active_group_brief = None
if active_group_id is not None:
group = session.get(Group, active_group_id)
@@ -468,7 +475,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
active_group_brief = {
"id": group.id,
"name": group.name,
**_overall_for_user(session, user_id, active_group_id),
**_overall_from_rows(_for_group(rows, active_group_id)),
}
return {
"leaderboard": board["entries"][:leaderboard_limit],