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

@ -1070,8 +1070,40 @@ class LichessBot:
# Only send response if there's activity # Only send response if there's activity
if has_activity: if has_activity:
formatted_response = StatsFormatter.format_stats_response(data, username, period, lang) # Дополнительный, параллельный запрос за реальными партиями периода —
await update.message.reply_text(formatted_response) # только чтобы добавить точность (accuracy) и, для today/yesterday,
# построчный разбор партий. Шапку (данные из /activity) не трогаем:
# при любой ошибке здесь просто не покажем точность.
accuracy_extra = None
try:
now_dt = datetime.now()
if period == "today":
since_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
until_dt = now_dt
want_rows = True
elif period == "yesterday":
today_midnight = now_dt.replace(hour=0, minute=0, second=0, microsecond=0)
since_dt = today_midnight - timedelta(days=1)
until_dt = today_midnight
want_rows = True
else: # week — то же окно (today-6 .. now), что использует _is_date_in_range(days_back=7)
since_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=6)
until_dt = now_dt
want_rows = False
accuracy_extra = await self.lichess_api.get_games_period(
username,
int(since_dt.timestamp() * 1000),
int(until_dt.timestamp() * 1000),
rated_only=True,
include_games=want_rows
)
except Exception as e:
logger.warning(f"⚠️ Supplementary accuracy fetch failed for {username}/{period}: {e}")
accuracy_extra = None
formatted_response = StatsFormatter.format_stats_response(data, username, period, lang, accuracy_extra=accuracy_extra)
await update.message.reply_text(formatted_response, parse_mode='Markdown')
has_any_activity = True has_any_activity = True
else: else:
logger.info(f" No activity found for {username}, skipping response") logger.info(f" No activity found for {username}, skipping response")
@ -1159,7 +1191,7 @@ class LichessBot:
if games_count > 0: if games_count > 0:
# Format and send immediately # Format and send immediately
text = StatsFormatter.format_last_year_or_1000(data, username, lang) text = StatsFormatter.format_last_year_or_1000(data, username, lang)
await update.message.reply_text(text) await update.message.reply_text(text, parse_mode='Markdown')
has_any_activity = True has_any_activity = True
# Wait 3 seconds before next request (except after the last one) # Wait 3 seconds before next request (except after the last one)
@ -1585,7 +1617,8 @@ class LichessBot:
logger.info(f"📥 Adding games request to queue for {gamer['username']}") logger.info(f"📥 Adding games request to queue for {gamer['username']}")
games_data = await self.request_queue.add_request( games_data = await self.request_queue.add_request(
self.lichess_api.get_games_period, self.lichess_api.get_games_period,
gamer['username'], since_timestamp, until_timestamp_approx gamer['username'], since_timestamp, until_timestamp_approx,
include_games=True
) )
if games_data is None: if games_data is None:
raise RuntimeError("Games period API returned no data") raise RuntimeError("Games period API returned no data")
@ -1695,7 +1728,8 @@ class LichessBot:
await self.application.bot.send_message( await self.application.bot.send_message(
chat_id=user_id, chat_id=user_id,
text=notification text=notification,
parse_mode='Markdown'
) )
logger.info(f"✅ Sent periodic notification for {gamer['username']} to user {user_id}") logger.info(f"✅ Sent periodic notification for {gamer['username']} to user {user_id}")
# Increment periodic notification counter # Increment periodic notification counter

View file

@ -14,7 +14,96 @@ class StatsFormatter:
return "⚪ 0" return "⚪ 0"
@staticmethod @staticmethod
def format_stats_response(data: Dict[str, Any], username: str, period: str, lang: str = 'en') -> str: def _escape_md(text: str) -> str:
"""Escape literal Markdown-special chars in dynamic text (e.g. usernames) for legacy Telegram Markdown"""
if not text:
return text
for ch in ('_', '*', '`', '['):
text = text.replace(ch, '\\' + ch)
return text
@staticmethod
def _format_accuracy(accuracy: Optional[float]) -> str:
"""Format accuracy percentage, or '-' if not available (game not analyzed)"""
if accuracy is None:
return "-"
return f"{accuracy:.0f}%"
@staticmethod
def _format_result(result: str) -> str:
"""Format a raw '1-0'/'0-1'/'1/2-1/2' result string for display"""
return "½-½" if result == "1/2-1/2" else result
@staticmethod
def _format_row_outcome_circle(row: dict) -> str:
"""
Colored circle for the tracked user's outcome in this row:
🟢 win, 🔴 loss, draw (or unknown side).
"""
tracked_is_white = row.get('tracked_is_white')
result = row.get('result', '')
if tracked_is_white is None or result == "1/2-1/2":
return ""
tracked_won = (result == "1-0" and tracked_is_white) or (result == "0-1" and not tracked_is_white)
return "🟢" if tracked_won else "🔴"
@staticmethod
def _format_rating_diff_paren(diff: Optional[int]) -> str:
"""Format the tracked user's rating change for this game, e.g. '(+5)' / '(-7)' / '(0)'"""
diff = diff or 0
if diff > 0:
return f"(+{diff})"
elif diff < 0:
return f"({diff})"
else:
return "(0)"
@staticmethod
def _format_game_rows_block(rows: list) -> str:
"""
Format a list of individual game rows as a column-aligned, monospace table:
outcome | rating_diff | accuracy1 | White | rating1 | result | Black | rating2 | accuracy2
Column widths are computed from the actual data so every column lines up
character-for-character. Caller is expected to wrap the result in a
Markdown ``` code block ``` so Telegram renders it with a monospace font.
"""
if not rows:
return ""
NAME_WIDTH = 7
prepared = []
for row in rows:
circle = StatsFormatter._format_row_outcome_circle(row)
diff_str = StatsFormatter._format_rating_diff_paren(row.get('tracked_rating_diff'))
white = (row.get('white_name') or '?')[:NAME_WIDTH]
black = (row.get('black_name') or '?')[:NAME_WIDTH]
wr = row.get('white_rating')
br = row.get('black_rating')
wr_str = str(wr) if wr is not None else "-"
br_str = str(br) if br is not None else "-"
wa = StatsFormatter._format_accuracy(row.get('white_accuracy'))
ba = StatsFormatter._format_accuracy(row.get('black_accuracy'))
result = StatsFormatter._format_result(row.get('result', ''))
prepared.append((circle, diff_str, wa, white, wr_str, result, black, br_str, ba))
diff_width = max(len(p[1]) for p in prepared)
acc_width = max(max(len(p[2]), len(p[8])) for p in prepared)
name_width = NAME_WIDTH
rating_width = max(max(len(p[4]), len(p[7])) for p in prepared)
result_width = max(len(p[5]) for p in prepared)
lines = []
for circle, diff_str, wa, white, wr_str, result, black, br_str, ba in prepared:
lines.append(
f"{circle} {diff_str:>{diff_width}} {wa:>{acc_width}} {white:<{name_width}} {wr_str:>{rating_width}} "
f"{result:^{result_width}} {black:<{name_width}} {br_str:>{rating_width}} {ba:>{acc_width}}"
)
return "\n".join(lines)
@staticmethod
def format_stats_response(data: Dict[str, Any], username: str, period: str, lang: str = 'en', accuracy_extra: Optional[Dict[str, Any]] = None) -> str:
"""Format statistics response according to the template""" """Format statistics response according to the template"""
if not data or data.get('data') is None: if not data or data.get('data') is None:
message = data.get('message', t('no_data', lang)) if data else t('no_data', lang) message = data.get('message', t('no_data', lang)) if data else t('no_data', lang)
@ -39,6 +128,10 @@ class StatsFormatter:
unsolved = tasks.get('unsolved', 0) unsolved = tasks.get('unsolved', 0)
task_text = t('puzzles_section', lang, total=total_tasks, solved=solved, unsolved=unsolved) task_text = t('puzzles_section', lang, total=total_tasks, solved=solved, unsolved=unsolved)
# Supplementary per-mode accuracy (+ per-game rows for today/yesterday), from a parallel
# games-of-period fetch. Purely additive: absent/None just means no accuracy shown.
accuracy_by_mode = (accuracy_extra.get('data') or {}) if accuracy_extra else {}
# Format games section # Format games section
games_text = "" games_text = ""
if games: if games:
@ -62,21 +155,50 @@ class StatsFormatter:
# Get game type name (capitalize first letter) # Get game type name (capitalize first letter)
game_type_name = game_type.title() game_type_name = game_type.title()
games_text += t('games_section', lang, # Точность в шапке имеет смысл только там, где нет построчного разбора партий
emoji=emoji, # ниже (week) — для today/yesterday она была бы избыточна рядом со строками партий.
game_type=game_type_name, if game_type in ('blitz', 'rapid', 'classical') and period == "week":
games_count=games_count, mode_accuracy = (accuracy_by_mode.get(game_type) or {}).get('accuracy')
rating_change=rating_change_str, games_text += t('games_section_with_accuracy', lang,
rating=rating, emoji=emoji,
wins=wins, game_type=game_type_name,
losses=losses, games_count=games_count,
draws=draws rating_change=rating_change_str,
) rating=rating,
wins=wins,
losses=losses,
draws=draws,
accuracy=StatsFormatter._format_accuracy(mode_accuracy)
)
else:
games_text += t('games_section', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str,
rating=rating,
wins=wins,
losses=losses,
draws=draws
)
# Per-game row breakdown (today/yesterday only — week/lastYear stay aggregate-only)
rows_text = ""
if period in ("today", "yesterday"):
for mode in ("blitz", "rapid", "classical"):
rows = (accuracy_by_mode.get(mode) or {}).get('games') or []
if not rows:
continue
emoji = StatsFormatter._get_game_type_emoji(mode)
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=mode.title())
rows_text += "```\n" + StatsFormatter._format_game_rows_block(rows) + "\n```\n\n"
# Combine all parts # Combine all parts
result = t('stats_title', lang, username=username, date_range=date_range) result = t('stats_title', lang, username=StatsFormatter._escape_md(username), date_range=date_range)
result += task_text result += task_text
result += games_text.rstrip() result += games_text.rstrip()
if rows_text:
result += "\n\n" + rows_text.rstrip()
return result return result
@ -123,7 +245,7 @@ class StatsFormatter:
else: else:
period_text = t('period_minutes_text', lang, period=period_minutes) period_text = t('period_minutes_text', lang, period=period_minutes)
result = t('period_notification_title', lang, username=username, period_text=period_text) result = t('period_notification_title', lang, username=StatsFormatter._escape_md(username), period_text=period_text)
# Format puzzles first (if available and there's actual activity) # Format puzzles first (if available and there's actual activity)
has_puzzles_data = False has_puzzles_data = False
@ -157,6 +279,7 @@ class StatsFormatter:
effective_games_count = top_level_games_count if top_level_games_count > 0 else total_games effective_games_count = top_level_games_count if top_level_games_count > 0 else total_games
# Show details for each game type if there were games # Show details for each game type if there were games
rows_text = ""
if effective_games_count > 0: if effective_games_count > 0:
for game_type, game_data in games_info.items(): for game_type, game_data in games_info.items():
if game_type != 'total' and game_data and game_data.get('games_played', 0) > 0: if game_type != 'total' and game_data and game_data.get('games_played', 0) > 0:
@ -172,16 +295,36 @@ class StatsFormatter:
rating_change_str = StatsFormatter._format_rating_change(rating_change) rating_change_str = StatsFormatter._format_rating_change(rating_change)
game_type_name = game_type.title() game_type_name = game_type.title()
result += t('period_games_section', lang, if game_type in ('blitz', 'rapid', 'classical'):
emoji=emoji, # Точность в шапку не выводим — она всегда дублируется построчным
game_type=game_type_name, # разбором партий ниже (периодическая проверка — короткий период).
games_count=games_count, result += t('period_games_section', lang,
rating_change=rating_change_str, emoji=emoji,
rating=rating, game_type=game_type_name,
wins=wins, games_count=games_count,
losses=losses, rating_change=rating_change_str,
draws=draws rating=rating,
) wins=wins,
losses=losses,
draws=draws
)
rows = game_data.get('games') or []
if rows:
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=game_type_name)
rows_text += "```\n" + StatsFormatter._format_game_rows_block(rows) + "\n```\n\n"
else:
result += t('period_games_section', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str,
rating=rating,
wins=wins,
losses=losses,
draws=draws
)
if rows_text:
result += rows_text
# If no activity at all # If no activity at all
if not has_games_data and not has_puzzles_data: if not has_games_data and not has_puzzles_data:
@ -202,14 +345,15 @@ class StatsFormatter:
period_end = data.get('period_end') period_end = data.get('period_end')
stats = (data.get('data') or {}) stats = (data.get('data') or {})
# Title and subheader # Title and subheader
escaped_username = StatsFormatter._escape_md(username)
if games_count >= 1000: if games_count >= 1000:
header = f"📈 {username}: last 1000 rated games" header = f"📈 {escaped_username}: last 1000 rated games"
earliest_ts = data.get('earliest_game_ts') earliest_ts = data.get('earliest_game_ts')
if isinstance(earliest_ts, int): if isinstance(earliest_ts, int):
earliest = datetime.fromtimestamp(earliest_ts).strftime("%d.%m.%Y") earliest = datetime.fromtimestamp(earliest_ts).strftime("%d.%m.%Y")
header += f"\n\n\nStart of these 1000 games: {earliest}" header += f"\n\n\nStart of these 1000 games: {earliest}"
else: else:
header = f"📈 {username}: last year (rated), games: {games_count}" header = f"📈 {escaped_username}: last year (rated), games: {games_count}"
# Use earliest actual game date instead of naive 'year ago' # Use earliest actual game date instead of naive 'year ago'
earliest_ts = data.get('earliest_game_ts', period_start) earliest_ts = data.get('earliest_game_ts', period_start)
if isinstance(earliest_ts, int) and isinstance(period_end, int): if isinstance(earliest_ts, int) and isinstance(period_end, int):
@ -233,9 +377,10 @@ class StatsFormatter:
rating_change_str = StatsFormatter._format_rating_change(rating_change) rating_change_str = StatsFormatter._format_rating_change(rating_change)
rating = mode_stats.get('rating') rating = mode_stats.get('rating')
rating_str = rating if rating is not None else "" rating_str = rating if rating is not None else ""
lines.append( line = f"{emoji} {mode.title()}: {games_played} Δ {rating_change_str} R {rating_str}{wins}{losses} 🤝 {draws}"
f"{emoji} {mode.title()}: {games_played} Δ {rating_change_str} R {rating_str}{wins}{losses} 🤝 {draws}" if mode in ("blitz", "rapid", "classical"):
) line += f" 🎯 {StatsFormatter._format_accuracy(mode_stats.get('accuracy'))}"
lines.append(line)
# Join lines with newlines between each mode # Join lines with newlines between each mode
# Between regular modes: one empty line (\n\n) # Between regular modes: one empty line (\n\n)
# Before last mode: two empty lines (\n\n\n) # Before last mode: two empty lines (\n\n\n)

View file

@ -93,6 +93,8 @@ TRANSLATIONS = {
'stats_title': "📊 Statistics {username}{date_range}\n\n", 'stats_title': "📊 Statistics {username}{date_range}\n\n",
'puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {unsolved})\n\n", 'puzzles_section': "🧩 Puzzles: {total} (✅ {solved} - ❌ {unsolved})\n\n",
'games_section': "{emoji} {game_type}{games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n\n", 'games_section': "{emoji} {game_type}{games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n\n",
'games_section_with_accuracy': "{emoji} {game_type}{games_count} games • {rating_change}\nRating: {rating}\n✅ Wins: {wins}\n❌ Losses: {losses}\n🤝 Draws: {draws}\n🎯 Accuracy: {accuracy}\n\n",
'game_rows_heading': "{emoji} {game_type} games:\n",
# Set period # Set period
'select_gamer_for_period': "⏱️ <b>Select player to set notification period:</b>\n\n", 'select_gamer_for_period': "⏱️ <b>Select player to set notification period:</b>\n\n",
@ -228,6 +230,8 @@ TRANSLATIONS = {
'stats_title': "📊 Статистика {username}{date_range}\n\n", 'stats_title': "📊 Статистика {username}{date_range}\n\n",
'puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {unsolved})\n\n", 'puzzles_section': "🧩 Пазлы: {total} (✅ {solved} - ❌ {unsolved})\n\n",
'games_section': "{emoji} {game_type}{games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n\n", 'games_section': "{emoji} {game_type}{games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n\n",
'games_section_with_accuracy': "{emoji} {game_type}{games_count} игр • {rating_change}\nРейтинг: {rating}\n✅ Побед: {wins}\n❌ Поражений: {losses}\n🤝 Ничьих: {draws}\n🎯 Точность: {accuracy}\n\n",
'game_rows_heading': "{emoji} Партии {game_type}:\n",
# Set period # Set period
'select_gamer_for_period': "⏱️ <b>Выберите игрока для установки периода уведомлений:</b>\n\n", 'select_gamer_for_period': "⏱️ <b>Выберите игрока для установки периода уведомлений:</b>\n\n",

View file

@ -98,7 +98,7 @@ class LichessAPI:
logger.error(f"Error getting week stats: {e}") logger.error(f"Error getting week stats: {e}")
return None return None
async def get_games_period(self, username: str, since: int, until: int, rated_only: Optional[bool] = None) -> Optional[Dict[str, Any]]: async def get_games_period(self, username: str, since: int, until: int, rated_only: Optional[bool] = None, include_games: bool = False) -> Optional[Dict[str, Any]]:
"""Get games for a specific period""" """Get games for a specific period"""
await self.rate_limiter.wait_if_needed() await self.rate_limiter.wait_if_needed()
try: try:
@ -106,6 +106,8 @@ class LichessAPI:
params = {"since": since, "until": until} params = {"since": since, "until": until}
if rated_only is not None: if rated_only is not None:
params["rated_only"] = "true" if rated_only else "false" params["rated_only"] = "true" if rated_only else "false"
if include_games:
params["include_games"] = "true"
logger.info(f"🔍 LichessAPI.get_games_period: URL={url}, params={params}") logger.info(f"🔍 LichessAPI.get_games_period: URL={url}, params={params}")
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:

View file

@ -127,7 +127,8 @@ class LichessClient:
params = { params = {
'since': since_ms, # Начало периода 'since': since_ms, # Начало периода
'until': until_ms, # Конец периода 'until': until_ms, # Конец периода
'max': 1000 # Максимум игр за запрос (лимит Lichess API) 'max': 1000, # Максимум игр за запрос (лимит Lichess API)
'accuracy': 'true' # Включить players.<color>.analysis.accuracy для проанализированных партий
} }
# Заголовки для получения NDJSON формата # Заголовки для получения NDJSON формата

View file

@ -500,7 +500,10 @@ async def get_games_of_period(
example=1641081600000), example=1641081600000),
rated_only: bool = Query(True, rated_only: bool = Query(True,
description="Только рейтинговые игры (по умолчанию true - рекомендуется)", description="Только рейтинговые игры (по умолчанию true - рекомендуется)",
example=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 since_seconds = since // 1000
until_seconds = until // 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: if not result.success:
# Реальная ошибка при обращении к Lichess — не маскируем её под "0 игр" # Реальная ошибка при обращении к Lichess — не маскируем её под "0 игр"
raise HTTPException(status_code=502, detail=result.message) raise HTTPException(status_code=502, detail=result.message)

View file

@ -13,7 +13,7 @@ Lichess Statistics API - Модели данных
""" """
from pydantic import BaseModel, Field 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="Информация о пользователе") user: Optional[Dict[str, Any]] = Field(None, description="Информация о пользователе")
rating: Optional[int] = Field(None, description="Рейтинг игрока") rating: Optional[int] = Field(None, description="Рейтинг игрока")
ratingDiff: Optional[int] = Field(None, description="Изменение рейтинга") ratingDiff: Optional[int] = Field(None, description="Изменение рейтинга")
analysis: Optional[Dict[str, Any]] = Field(None, description="Пост-анализ партии (только если игрок запускал анализ на Lichess), содержит поле 'accuracy'")
class Game(BaseModel): class Game(BaseModel):
""" """
@ -147,6 +148,23 @@ class Game(BaseModel):
winner: Optional[str] = Field(None, description="Победитель (white, black или null)") winner: Optional[str] = Field(None, description="Победитель (white, black или null)")
moves: str = Field(..., description="Ходы игры в PGN формате") 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): class GameStats(BaseModel):
""" """
Статистика игр по конкретному типу (Bullet, Blitz, Rapid и т.д.). Статистика игр по конкретному типу (Bullet, Blitz, Rapid и т.д.).
@ -163,6 +181,8 @@ class GameStats(BaseModel):
draws: int = Field(..., description="Количество ничьих", example=1) draws: int = Field(..., description="Количество ничьих", example=1)
rating_change: int = Field(..., description="Общее изменение рейтинга", example=15) rating_change: int = Field(..., description="Общее изменение рейтинга", example=15)
rating: Optional[int] = Field(None, description="Итоговый рейтинг после последней игры (только если games_played > 0)", example=2850) 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): class GamesOfPeriodStats(BaseModel):
""" """

View file

@ -16,7 +16,7 @@ Lichess Statistics API - Сервис обработки статистики
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from lichess_client import LichessClient 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 import logging
# Настройка логирования для модуля # Настройка логирования для модуля
@ -537,16 +537,82 @@ class StatsService:
return rating_diff, final_rating 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 = { stats = {
'bullet': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}, '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}, '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}, '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}, '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}, '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} 'total': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}
} }
@ -594,6 +660,15 @@ class StatsService:
if final_rating is not None: if final_rating is not None:
stats[speed]['rating'] = final_rating 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 stats['total']['games_played'] += 1
if result == 'win': if result == 'win':
@ -609,14 +684,18 @@ class StatsService:
# Создаем объекты GameStats, устанавливая rating только для режимов с играми # Создаем объекты GameStats, устанавливая rating только для режимов с играми
def create_game_stats(mode_stats): 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 только если были игры # Устанавливаем rating только если были игры
if mode_stats['games_played'] > 0 and mode_stats['rating'] is not None: if not (mode_stats['games_played'] > 0 and mode_stats['rating'] is not None):
return GameStats(**mode_stats) mode_stats['rating'] = None
else: return GameStats(**mode_stats)
# Убираем rating для режимов без игр
mode_stats_copy = mode_stats.copy()
mode_stats_copy['rating'] = None
return GameStats(**mode_stats_copy)
return GamesOfPeriodStats( return GamesOfPeriodStats(
bullet=create_game_stats(stats['bullet']), bullet=create_game_stats(stats['bullet']),
@ -627,7 +706,7 @@ class StatsService:
total=create_game_stats(stats['total']) 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:
""" """
Получает статистику игр пользователя за определенный период. Получает статистику игр пользователя за определенный период.
@ -639,6 +718,7 @@ class StatsService:
since_timestamp: Начало периода (Unix timestamp в секундах) since_timestamp: Начало периода (Unix timestamp в секундах)
until_timestamp: Конец периода (Unix timestamp в секундах) until_timestamp: Конец периода (Unix timestamp в секундах)
rated_only: Только рейтинговые игры (по умолчанию True) rated_only: Только рейтинговые игры (по умолчанию True)
include_games: Включить построчный список отдельных партий (только blitz/rapid/classical)
Returns: Returns:
GamesOfPeriodResponse с статистикой игр 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 earliest_game_ts = None
try: try: