Update GPT and add Profile

This commit is contained in:
2025-12-16 22:12:12 +07:00
parent 08b96fd1f7
commit 696dc714c4
15 changed files with 1063 additions and 90 deletions

View File

@@ -14,12 +14,10 @@ from app.schemas import (
ChallengesPreviewResponse,
ChallengesSaveRequest,
)
from app.services.gpt import GPTService
from app.services.gpt import gpt_service
router = APIRouter(tags=["challenges"])
gpt_service = GPTService()
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute(
@@ -215,22 +213,35 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
if not games:
raise HTTPException(status_code=400, detail="No approved games in marathon")
preview_challenges = []
# Filter games that don't have challenges yet
games_to_generate = []
game_map = {}
for game in games:
# Check if game already has challenges
existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
)
if existing:
continue # Skip if already has challenges
if not existing:
games_to_generate.append({
"id": game.id,
"title": game.title,
"genre": game.genre
})
game_map[game.id] = game.title
try:
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
if not games_to_generate:
return ChallengesPreviewResponse(challenges=[])
# Generate challenges for all games in one API call
preview_challenges = []
try:
challenges_by_game = await gpt_service.generate_challenges(games_to_generate)
for game_id, challenges_data in challenges_by_game.items():
game_title = game_map.get(game_id, "Unknown")
for ch_data in challenges_data:
preview_challenges.append(ChallengePreview(
game_id=game.id,
game_title=game.title,
game_id=game_id,
game_title=game_title,
title=ch_data.title,
description=ch_data.description,
type=ch_data.type,
@@ -241,9 +252,8 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
proof_hint=ch_data.proof_hint,
))
except Exception as e:
# Log error but continue with other games
print(f"Error generating challenges for {game.title}: {e}")
except Exception as e:
print(f"Error generating challenges: {e}")
return ChallengesPreviewResponse(challenges=preview_challenges)

View File

@@ -1,10 +1,16 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File
from sqlalchemy import select
from sqlalchemy import select, func
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.models import User
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
from app.core.security import verify_password, get_password_hash
from app.models import User, Participant, Assignment, Marathon
from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus
from app.schemas import (
UserPublic, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic,
)
from app.services.storage import storage_service
router = APIRouter(prefix="/users", tags=["users"])
@@ -125,3 +131,142 @@ async def unlink_telegram(
await db.commit()
return MessageResponse(message="Telegram account unlinked successfully")
@router.post("/me/password", response_model=MessageResponse)
async def change_password(
data: PasswordChange,
current_user: CurrentUser,
db: DbSession,
):
"""Смена пароля текущего пользователя"""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль",
)
if data.current_password == data.new_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новый пароль должен отличаться от текущего",
)
current_user.password_hash = get_password_hash(data.new_password)
await db.commit()
return MessageResponse(message="Пароль успешно изменен")
@router.get("/me/stats", response_model=UserStats)
async def get_my_stats(current_user: CurrentUser, db: DbSession):
"""Получить свою статистику"""
return await _get_user_stats(current_user.id, db)
@router.get("/{user_id}/stats", response_model=UserStats)
async def get_user_stats(user_id: int, db: DbSession):
"""Получить статистику пользователя"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return await _get_user_stats(user_id, db)
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession):
"""Получить публичный профиль пользователя со статистикой"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
stats = await _get_user_stats(user_id, db)
return UserProfilePublic(
id=user.id,
nickname=user.nickname,
avatar_url=user.avatar_url,
created_at=user.created_at,
stats=stats,
)
async def _get_user_stats(user_id: int, db) -> UserStats:
"""Вспомогательная функция для подсчета статистики пользователя"""
# 1. Количество марафонов (участий)
marathons_result = await db.execute(
select(func.count(Participant.id))
.where(Participant.user_id == user_id)
)
marathons_count = marathons_result.scalar() or 0
# 2. Количество побед (1 место в завершенных марафонах)
wins_count = 0
user_participations = await db.execute(
select(Participant)
.join(Marathon, Marathon.id == Participant.marathon_id)
.where(
Participant.user_id == user_id,
Marathon.status == MarathonStatus.FINISHED.value
)
)
for participation in user_participations.scalars():
# Для каждого марафона проверяем, был ли пользователь первым
max_points_result = await db.execute(
select(func.max(Participant.total_points))
.where(Participant.marathon_id == participation.marathon_id)
)
max_points = max_points_result.scalar() or 0
if participation.total_points == max_points and max_points > 0:
# Проверяем что он единственный с такими очками (не ничья)
count_with_max = await db.execute(
select(func.count(Participant.id))
.where(
Participant.marathon_id == participation.marathon_id,
Participant.total_points == max_points
)
)
if count_with_max.scalar() == 1:
wins_count += 1
# 3. Выполненных заданий
completed_result = await db.execute(
select(func.count(Assignment.id))
.join(Participant, Participant.id == Assignment.participant_id)
.where(
Participant.user_id == user_id,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_assignments = completed_result.scalar() or 0
# 4. Всего очков заработано
points_result = await db.execute(
select(func.coalesce(func.sum(Assignment.points_earned), 0))
.join(Participant, Participant.id == Assignment.participant_id)
.where(
Participant.user_id == user_id,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
total_points_earned = points_result.scalar() or 0
return UserStats(
marathons_count=marathons_count,
wins_count=wins_count,
completed_assignments=completed_assignments,
total_points_earned=total_points_earned,
)

View File

@@ -6,6 +6,9 @@ from app.schemas.user import (
UserWithTelegram,
TokenResponse,
TelegramLink,
PasswordChange,
UserStats,
UserProfilePublic,
)
from app.schemas.marathon import (
MarathonCreate,
@@ -87,6 +90,9 @@ __all__ = [
"UserWithTelegram",
"TokenResponse",
"TelegramLink",
"PasswordChange",
"UserStats",
"UserProfilePublic",
# Marathon
"MarathonCreate",
"MarathonUpdate",

View File

@@ -58,3 +58,28 @@ class TokenResponse(BaseModel):
class TelegramLink(BaseModel):
telegram_id: int
telegram_username: str | None = None
class PasswordChange(BaseModel):
current_password: str = Field(..., min_length=6)
new_password: str = Field(..., min_length=6, max_length=100)
class UserStats(BaseModel):
"""Статистика пользователя по марафонам"""
marathons_count: int
wins_count: int
completed_assignments: int
total_points_earned: int
class UserProfilePublic(BaseModel):
"""Публичный профиль пользователя со статистикой"""
id: int
nickname: str
avatar_url: str | None = None
created_at: datetime
stats: UserStats
class Config:
from_attributes = True

View File

@@ -13,101 +13,131 @@ class GPTService:
async def generate_challenges(
self,
game_title: str,
game_genre: str | None = None
) -> list[ChallengeGenerated]:
games: list[dict]
) -> dict[int, list[ChallengeGenerated]]:
"""
Generate challenges for a game using GPT.
Generate challenges for multiple games in one API call.
Args:
game_title: Name of the game
game_genre: Optional genre of the game
games: List of dicts with keys: id, title, genre
Returns:
List of generated challenges
Dict mapping game_id to list of generated challenges
"""
genre_text = f" (жанр: {game_genre})" if game_genre else ""
if not games:
return {}
prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй 6 КОНКРЕТНЫХ челленджей для игры "{game_title}"{genre_text}.
games_text = "\n".join([
f"- {g['title']}" + (f" (жанр: {g['genre']})" if g.get('genre') else "")
for g in games
])
ВАЖНО: Челленджи должны быть СПЕЦИФИЧНЫМИ для этой игры!
prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй по 6 КОНКРЕТНЫХ челленджей для каждой из следующих игр:
{games_text}
ВАЖНО: Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры!
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре (спидраны, no-hit боссов, сбор коллекционных предметов и т.д.)
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
Примеры ХОРОШИХ челленджей:
- Dark Souls: "Победи Орнштейна и Смоуга без призыва" / "Пройди Чумной город без отравления"
- GTA V: "Получи золото в миссии «Ювелирное дело»" / "Выиграй уличную гонку на Vinewood"
- Hollow Knight: "Победи Хорнет без получения урона" / "Найди все грибные споры в Грибных пустошах"
- Minecraft: "Убей Дракона Края за один визит в Энд" / "Построй работающую ферму железа"
Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ:
- 2 лёгких (15-30 мин): простые задачи
- 2 средних (1-2 часа): требуют навыка
- 2 сложных (3+ часа): серьёзный челлендж
Требования по сложности:
- 2 лёгких (15-30 мин): простые задачи, знакомство с игрой
- 2 средних (1-2 часа): требуют навыка или исследования
- 2 сложных (3+ часа): серьёзный челлендж, достижения, полное прохождение
Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе:
{{
"Название игры 1": {{
"challenges": [
{{"title": "...", "description": "...", "type": "completion|no_death|speedrun|collection|achievement|challenge_run", "difficulty": "easy|medium|hard", "points": 50, "estimated_time": 30, "proof_type": "screenshot|video|steam", "proof_hint": "..."}}
]
}},
"Название игры 2": {{
"challenges": [...]
}}
}}
Формат ответа — JSON:
- title: название на русском (до 50 символов), конкретное и понятное
- description: что именно сделать (1-2 предложения), с деталями из игры
- type: completion | no_death | speedrun | collection | achievement | challenge_run
- difficulty: easy | medium | hard
- points: easy=20-40, medium=45-75, hard=90-150
- estimated_time: время в минутах
- proof_type: screenshot | video | steam
- proof_hint: ЧТО КОНКРЕТНО должно быть видно на скриншоте/видео (экран победы, достижение, локация и т.д.)
Ответь ТОЛЬКО JSON:
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
points: easy=20-40, medium=45-75, hard=90-150
Ответь ТОЛЬКО JSON."""
response = await self.client.chat.completions.create(
model="gpt-5-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.8,
max_tokens=2500,
)
content = response.choices[0].message.content
data = json.loads(content)
challenges = []
for ch in data.get("challenges", []):
# Validate and normalize type
ch_type = ch.get("type", "completion")
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
# Map game titles to IDs (case-insensitive, strip whitespace)
title_to_id = {g['title'].lower().strip(): g['id'] for g in games}
# Validate difficulty
difficulty = ch.get("difficulty", "medium")
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
# Also keep original titles for logging
id_to_title = {g['id']: g['title'] for g in games}
# Validate proof_type
proof_type = ch.get("proof_type", "screenshot")
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
print(f"[GPT] Requested games: {[g['title'] for g in games]}")
print(f"[GPT] Response keys: {list(data.keys())}")
# Validate points based on difficulty
points = ch.get("points", 30)
if not isinstance(points, int) or points < 1:
points = 30
# Clamp points to expected ranges
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
result = {}
for game_title, game_data in data.items():
# Try exact match first, then case-insensitive
game_id = title_to_id.get(game_title.lower().strip())
challenges.append(ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],
description=ch.get("description", "Complete the challenge"),
type=ch_type,
difficulty=difficulty,
points=points,
estimated_time=ch.get("estimated_time"),
proof_type=proof_type,
proof_hint=ch.get("proof_hint"),
))
if not game_id:
# Try partial match if exact match fails
for stored_title, gid in title_to_id.items():
if stored_title in game_title.lower() or game_title.lower() in stored_title:
game_id = gid
break
return challenges
if not game_id:
print(f"[GPT] Could not match game: '{game_title}'")
continue
challenges = []
for ch in game_data.get("challenges", []):
challenges.append(self._parse_challenge(ch))
result[game_id] = challenges
print(f"[GPT] Generated {len(challenges)} challenges for '{id_to_title.get(game_id)}'")
return result
def _parse_challenge(self, ch: dict) -> ChallengeGenerated:
"""Parse and validate a single challenge from GPT response"""
ch_type = ch.get("type", "completion")
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
difficulty = ch.get("difficulty", "medium")
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
proof_type = ch.get("proof_type", "screenshot")
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
points = ch.get("points", 30)
if not isinstance(points, int) or points < 1:
points = 30
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
return ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],
description=ch.get("description", "Complete the challenge"),
type=ch_type,
difficulty=difficulty,
points=points,
estimated_time=ch.get("estimated_time"),
proof_type=proof_type,
proof_hint=ch.get("proof_hint"),
)
gpt_service = GPTService()