add per-game accuracy and outcome table to stats/notifications
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
Games-of-period responses can now include a per-game breakdown
(include_games) with Lichess post-analysis accuracy, plus which side
the tracked player was on and their rating change for that game.
- lichess_client.py requests accuracy=true from Lichess; stats_service
computes per-mode average accuracy and builds GameRow entries
(tracked_is_white, tracked_rating_diff) for blitz/rapid/classical
- /games/{username}/period gained an include_games query param
- formatters.py renders a column-aligned monospace table per game:
outcome circle (win/loss/draw relative to the tracked player),
rating change, accuracy, names/ratings, result — for today/yesterday
and periodic notifications; week keeps an aggregate accuracy line
- usernames are Markdown-escaped before formatting since messages are
now sent with parse_mode='Markdown'
This commit is contained in:
parent
4a783225af
commit
d479e14bf9
8 changed files with 364 additions and 75 deletions
|
|
@ -127,7 +127,8 @@ class LichessClient:
|
|||
params = {
|
||||
'since': since_ms, # Начало периода
|
||||
'until': until_ms, # Конец периода
|
||||
'max': 1000 # Максимум игр за запрос (лимит Lichess API)
|
||||
'max': 1000, # Максимум игр за запрос (лимит Lichess API)
|
||||
'accuracy': 'true' # Включить players.<color>.analysis.accuracy для проанализированных партий
|
||||
}
|
||||
|
||||
# Заголовки для получения NDJSON формата
|
||||
|
|
|
|||
|
|
@ -498,9 +498,12 @@ async def get_games_of_period(
|
|||
until: int = Query(...,
|
||||
description="Конец периода (Unix timestamp в миллисекундах)",
|
||||
example=1641081600000),
|
||||
rated_only: bool = Query(True,
|
||||
description="Только рейтинговые игры (по умолчанию true - рекомендуется)",
|
||||
example=True)
|
||||
rated_only: bool = Query(True,
|
||||
description="Только рейтинговые игры (по умолчанию true - рекомендуется)",
|
||||
example=True),
|
||||
include_games: bool = Query(False,
|
||||
description="Включить построчный список отдельных партий (только blitz/rapid/classical)",
|
||||
example=False)
|
||||
):
|
||||
"""
|
||||
## Статистика игр за период
|
||||
|
|
@ -554,7 +557,7 @@ async def get_games_of_period(
|
|||
# Конвертируем миллисекунды в секунды для внутренней логики
|
||||
since_seconds = since // 1000
|
||||
until_seconds = until // 1000
|
||||
result = await stats_service.get_games_of_period(username, since_seconds, until_seconds, rated_only)
|
||||
result = await stats_service.get_games_of_period(username, since_seconds, until_seconds, rated_only, include_games)
|
||||
if not result.success:
|
||||
# Реальная ошибка при обращении к Lichess — не маскируем её под "0 игр"
|
||||
raise HTTPException(status_code=502, detail=result.message)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Lichess Statistics API - Модели данных
|
|||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Optional, Any
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
# =============================================================================
|
||||
# МОДЕЛИ СТАТИСТИКИ ЗАДАЧ (ПАЗЛОВ)
|
||||
|
|
@ -125,6 +125,7 @@ class GamePlayer(BaseModel):
|
|||
user: Optional[Dict[str, Any]] = Field(None, description="Информация о пользователе")
|
||||
rating: Optional[int] = Field(None, description="Рейтинг игрока")
|
||||
ratingDiff: Optional[int] = Field(None, description="Изменение рейтинга")
|
||||
analysis: Optional[Dict[str, Any]] = Field(None, description="Пост-анализ партии (только если игрок запускал анализ на Lichess), содержит поле 'accuracy'")
|
||||
|
||||
class Game(BaseModel):
|
||||
"""
|
||||
|
|
@ -147,6 +148,23 @@ class Game(BaseModel):
|
|||
winner: Optional[str] = Field(None, description="Победитель (white, black или null)")
|
||||
moves: str = Field(..., description="Ходы игры в PGN формате")
|
||||
|
||||
class GameRow(BaseModel):
|
||||
"""
|
||||
Одна отдельная партия для построчного разбора (today/yesterday/периодические уведомления).
|
||||
|
||||
Порядок игроков — как в партии (белые/чёрные), а не "отслеживаемый пользователь/соперник".
|
||||
"""
|
||||
white_name: str = Field(..., description="Имя игрока белыми")
|
||||
black_name: str = Field(..., description="Имя игрока чёрными")
|
||||
white_rating: Optional[int] = Field(None, description="Рейтинг белых")
|
||||
black_rating: Optional[int] = Field(None, description="Рейтинг чёрных")
|
||||
white_accuracy: Optional[float] = Field(None, description="Точность белых, если партия анализировалась")
|
||||
black_accuracy: Optional[float] = Field(None, description="Точность чёрных, если партия анализировалась")
|
||||
result: str = Field(..., description="Результат партии: '1-0', '0-1' или '1/2-1/2'")
|
||||
created_at: int = Field(..., description="Время создания партии (Unix timestamp в миллисекундах)")
|
||||
tracked_is_white: Optional[bool] = Field(None, description="True, если отслеживаемый пользователь играл белыми; False — чёрными; None — не удалось определить сторону")
|
||||
tracked_rating_diff: Optional[int] = Field(None, description="Изменение рейтинга отслеживаемого пользователя по итогам этой партии")
|
||||
|
||||
class GameStats(BaseModel):
|
||||
"""
|
||||
Статистика игр по конкретному типу (Bullet, Blitz, Rapid и т.д.).
|
||||
|
|
@ -163,6 +181,8 @@ class GameStats(BaseModel):
|
|||
draws: int = Field(..., description="Количество ничьих", example=1)
|
||||
rating_change: int = Field(..., description="Общее изменение рейтинга", example=15)
|
||||
rating: Optional[int] = Field(None, description="Итоговый рейтинг после последней игры (только если games_played > 0)", example=2850)
|
||||
accuracy: Optional[float] = Field(None, description="Средняя точность отслеживаемого пользователя по проанализированным партиям этого режима; None если нет проанализированных партий", example=82.5)
|
||||
games: Optional[List[GameRow]] = Field(None, description="Построчный список отдельных партий (только blitz/rapid/classical), заполняется только если запрошено include_games=True")
|
||||
|
||||
class GamesOfPeriodStats(BaseModel):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Lichess Statistics API - Сервис обработки статистики
|
|||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta, date
|
||||
from lichess_client import LichessClient
|
||||
from models import UserStats, TaskStats, GameModeStats, GamesStats, ActivityResponse, GameStats, GamesOfPeriodStats, GamesOfPeriodResponse, PuzzleStats, PuzzleOfPeriodResponse
|
||||
from models import UserStats, TaskStats, GameModeStats, GamesStats, ActivityResponse, GameStats, GameRow, GamesOfPeriodStats, GamesOfPeriodResponse, PuzzleStats, PuzzleOfPeriodResponse
|
||||
import logging
|
||||
|
||||
# Настройка логирования для модуля
|
||||
|
|
@ -536,17 +536,83 @@ class StatsService:
|
|||
final_rating = rating_before + rating_diff
|
||||
|
||||
return rating_diff, final_rating
|
||||
|
||||
def _process_games_of_period(self, games: List[Dict[str, Any]], username: str) -> GamesOfPeriodStats:
|
||||
|
||||
def _get_accuracy(self, game: Dict[str, Any], username: str) -> Optional[float]:
|
||||
"""
|
||||
Получает точность (accuracy) отслеживаемого пользователя в партии,
|
||||
если партия была проанализирована на Lichess. Иначе None.
|
||||
"""
|
||||
players = game.get('players', {})
|
||||
username_lower = username.lower()
|
||||
white_user = players.get('white', {}).get('user', {}) or {}
|
||||
black_user = players.get('black', {}).get('user', {}) or {}
|
||||
|
||||
if white_user.get('name', '').lower() == username_lower:
|
||||
user_color = 'white'
|
||||
elif black_user.get('name', '').lower() == username_lower:
|
||||
user_color = 'black'
|
||||
else:
|
||||
return None
|
||||
|
||||
analysis = players.get(user_color, {}).get('analysis') or {}
|
||||
return analysis.get('accuracy')
|
||||
|
||||
def _build_game_row(self, game: Dict[str, Any], username: str) -> GameRow:
|
||||
"""
|
||||
Строит построчное представление одной партии (белые/чёрные в порядке партии).
|
||||
"""
|
||||
players = game.get('players', {})
|
||||
white = players.get('white', {}) or {}
|
||||
black = players.get('black', {}) or {}
|
||||
winner = game.get('winner')
|
||||
|
||||
if winner == 'white':
|
||||
result = "1-0"
|
||||
elif winner == 'black':
|
||||
result = "0-1"
|
||||
else:
|
||||
result = "1/2-1/2"
|
||||
|
||||
username_lower = username.lower()
|
||||
white_name = (white.get('user') or {}).get('name', '?')
|
||||
black_name = (black.get('user') or {}).get('name', '?')
|
||||
if white_name.lower() == username_lower:
|
||||
tracked_is_white = True
|
||||
elif black_name.lower() == username_lower:
|
||||
tracked_is_white = False
|
||||
else:
|
||||
tracked_is_white = None
|
||||
|
||||
if tracked_is_white is True:
|
||||
tracked_rating_diff = white.get('ratingDiff')
|
||||
elif tracked_is_white is False:
|
||||
tracked_rating_diff = black.get('ratingDiff')
|
||||
else:
|
||||
tracked_rating_diff = None
|
||||
|
||||
return GameRow(
|
||||
white_name=white_name,
|
||||
black_name=black_name,
|
||||
white_rating=white.get('rating'),
|
||||
black_rating=black.get('rating'),
|
||||
white_accuracy=(white.get('analysis') or {}).get('accuracy'),
|
||||
black_accuracy=(black.get('analysis') or {}).get('accuracy'),
|
||||
result=result,
|
||||
created_at=game.get('createdAt', 0),
|
||||
tracked_is_white=tracked_is_white,
|
||||
tracked_rating_diff=tracked_rating_diff
|
||||
)
|
||||
|
||||
def _process_games_of_period(self, games: List[Dict[str, Any]], username: str, include_games: bool = False) -> GamesOfPeriodStats:
|
||||
"""
|
||||
Обрабатывает игры за период и возвращает статистику
|
||||
"""
|
||||
# Инициализируем статистику для всех типов игр
|
||||
stats = {
|
||||
'bullet': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
||||
'blitz': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
||||
'rapid': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
||||
'classical': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
||||
'blitz': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None, 'accuracy_sum': 0.0, 'accuracy_count': 0, 'games': []},
|
||||
'rapid': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None, 'accuracy_sum': 0.0, 'accuracy_count': 0, 'games': []},
|
||||
'classical': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None, 'accuracy_sum': 0.0, 'accuracy_count': 0, 'games': []},
|
||||
'correspondence': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
|
||||
'total': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}
|
||||
}
|
||||
|
|
@ -593,7 +659,16 @@ class StatsService:
|
|||
# Сохраняем итоговый рейтинг после последней игры
|
||||
if final_rating is not None:
|
||||
stats[speed]['rating'] = final_rating
|
||||
|
||||
|
||||
# Точность и построчный разбор — только для blitz/rapid/classical
|
||||
if speed in ('blitz', 'rapid', 'classical'):
|
||||
accuracy = self._get_accuracy(game, username)
|
||||
if accuracy is not None:
|
||||
stats[speed]['accuracy_sum'] += accuracy
|
||||
stats[speed]['accuracy_count'] += 1
|
||||
if include_games:
|
||||
stats[speed]['games'].append(self._build_game_row(game, username))
|
||||
|
||||
# Обновляем общую статистику
|
||||
stats['total']['games_played'] += 1
|
||||
if result == 'win':
|
||||
|
|
@ -609,14 +684,18 @@ class StatsService:
|
|||
|
||||
# Создаем объекты GameStats, устанавливая rating только для режимов с играми
|
||||
def create_game_stats(mode_stats):
|
||||
mode_stats = mode_stats.copy()
|
||||
# accuracy_sum/accuracy_count/games присутствуют только у blitz/rapid/classical
|
||||
accuracy_sum = mode_stats.pop('accuracy_sum', 0.0)
|
||||
accuracy_count = mode_stats.pop('accuracy_count', 0)
|
||||
games_list = mode_stats.pop('games', [])
|
||||
mode_stats['accuracy'] = round(accuracy_sum / accuracy_count, 1) if accuracy_count > 0 else None
|
||||
if include_games and games_list:
|
||||
mode_stats['games'] = sorted(games_list, key=lambda r: r.created_at)
|
||||
# Устанавливаем rating только если были игры
|
||||
if mode_stats['games_played'] > 0 and mode_stats['rating'] is not None:
|
||||
return GameStats(**mode_stats)
|
||||
else:
|
||||
# Убираем rating для режимов без игр
|
||||
mode_stats_copy = mode_stats.copy()
|
||||
mode_stats_copy['rating'] = None
|
||||
return GameStats(**mode_stats_copy)
|
||||
if not (mode_stats['games_played'] > 0 and mode_stats['rating'] is not None):
|
||||
mode_stats['rating'] = None
|
||||
return GameStats(**mode_stats)
|
||||
|
||||
return GamesOfPeriodStats(
|
||||
bullet=create_game_stats(stats['bullet']),
|
||||
|
|
@ -627,19 +706,20 @@ class StatsService:
|
|||
total=create_game_stats(stats['total'])
|
||||
)
|
||||
|
||||
async def get_games_of_period(self, username: str, since_timestamp: int, until_timestamp: int, rated_only: bool = True) -> GamesOfPeriodResponse:
|
||||
async def get_games_of_period(self, username: str, since_timestamp: int, until_timestamp: int, rated_only: bool = True, include_games: bool = False) -> GamesOfPeriodResponse:
|
||||
"""
|
||||
Получает статистику игр пользователя за определенный период.
|
||||
|
||||
|
||||
Получает игры от Lichess API за указанный период, обрабатывает их
|
||||
и возвращает агрегированную статистику по режимам игр.
|
||||
|
||||
|
||||
Args:
|
||||
username: Имя пользователя на Lichess
|
||||
since_timestamp: Начало периода (Unix timestamp в секундах)
|
||||
until_timestamp: Конец периода (Unix timestamp в секундах)
|
||||
rated_only: Только рейтинговые игры (по умолчанию True)
|
||||
|
||||
include_games: Включить построчный список отдельных партий (только blitz/rapid/classical)
|
||||
|
||||
Returns:
|
||||
GamesOfPeriodResponse с статистикой игр
|
||||
"""
|
||||
|
|
@ -674,7 +754,7 @@ class StatsService:
|
|||
)
|
||||
|
||||
# Обрабатываем игры
|
||||
games_stats = self._process_games_of_period(games, username)
|
||||
games_stats = self._process_games_of_period(games, username, include_games=include_games)
|
||||
# Определяем время самой старой партии (в секундах)
|
||||
earliest_game_ts = None
|
||||
try:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue