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

@ -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 формата

View file

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

View file

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

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: