add per-game accuracy and outcome table to stats/notifications
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:
vrubelroman 2026-07-03 09:38:51 +00:00
parent 4a783225af
commit d479e14bf9
8 changed files with 364 additions and 75 deletions

View file

@ -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: