initial commit (backend)

This commit is contained in:
2026-09-20 11:45:58 +00:00
commit 033adf023e
65 changed files with 28292 additions and 0 deletions
View File
+530
View File
@@ -0,0 +1,530 @@
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
router = APIRouter(prefix="/achievements", tags=["achievements"])
# ============ Данные 52 достижений ============
ACHIEVEMENTS_DATA = [
# ===== Word Search (11) =====
{"code": "first_level", "category": "wordsearch", "icon": "🔰",
"name_pl": "Pierwszy krok", "name_ru": "Первый шаг", "name_uk": "Перший крок", "name_en": "First Step",
"desc_pl": "Ukończ 1 poziom filwordów", "desc_ru": "Пройти 1 уровень филвордов", "desc_uk": "Пройти 1 рівень філвордів", "desc_en": "Complete 1 word search level",
"type": "levels_completed", "value": 1},
{"code": "ten_levels", "category": "wordsearch", "icon": "",
"name_pl": "Gracz", "name_ru": "Игрок", "name_uk": "Гравець", "name_en": "Player",
"desc_pl": "Ukończ 10 poziomów filwordów", "desc_ru": "Пройти 10 уровней филвордов", "desc_uk": "Пройти 10 рівнів філвордів", "desc_en": "Complete 10 word search levels",
"type": "levels_completed", "value": 10},
{"code": "fifty_levels", "category": "wordsearch", "icon": "🏅",
"name_pl": "Ekspert", "name_ru": "Эксперт", "name_uk": "Експерт", "name_en": "Expert",
"desc_pl": "Ukończ 50 poziomów filwordów", "desc_ru": "Пройти 50 уровней филвордов", "desc_uk": "Пройти 50 рівнів філвордів", "desc_en": "Complete 50 word search levels",
"type": "levels_completed", "value": 50},
{"code": "hundred_levels", "category": "wordsearch", "icon": "👑",
"name_pl": "Król", "name_ru": "Король", "name_uk": "Король", "name_en": "King",
"desc_pl": "Ukończ 100 poziomów filwordów", "desc_ru": "Пройти 100 уровней филвордов", "desc_uk": "Пройти 100 рівнів філвордів", "desc_en": "Complete 100 word search levels",
"type": "levels_completed", "value": 100},
{"code": "ws_200", "category": "wordsearch", "icon": "🎯",
"name_pl": "Mistrz", "name_ru": "Мастер", "name_uk": "Майстер", "name_en": "Master",
"desc_pl": "Ukończ 200 poziomów filwordów", "desc_ru": "Пройти 200 уровней филвордов", "desc_uk": "Пройти 200 рівнів філвордів", "desc_en": "Complete 200 word search levels",
"type": "levels_completed", "value": 200},
{"code": "ws_500", "category": "wordsearch", "icon": "🌟",
"name_pl": "Legenda filwordów", "name_ru": "Легенда филвордов", "name_uk": "Легенда філвордів", "name_en": "Word Search Legend",
"desc_pl": "Ukończ 500 poziomów filwordów", "desc_ru": "Пройти 500 уровней филвордов", "desc_uk": "Пройти 500 рівнів філвордів", "desc_en": "Complete 500 word search levels",
"type": "levels_completed", "value": 500},
{"code": "no_hints", "category": "wordsearch", "icon": "💡",
"name_pl": "Bystrzak", "name_ru": "Умница", "name_uk": "Розумник", "name_en": "Smart",
"desc_pl": "Ukończ poziom bez podpowiedzi", "desc_ru": "Пройти уровень без подсказок", "desc_uk": "Пройти рівень без підказок", "desc_en": "Complete a level without hints",
"type": "no_hints_level", "value": 1},
{"code": "streak_5", "category": "wordsearch", "icon": "🔥",
"name_pl": "Seria", "name_ru": "Серия", "name_uk": "Серія", "name_en": "Streak",
"desc_pl": "Ukończ 5 poziomów z rzędu", "desc_ru": "Пройти 5 уровней подряд", "desc_uk": "Пройти 5 рівнів поспіль", "desc_en": "Complete 5 levels in a row",
"type": "level_streak", "value": 5},
{"code": "speed_60", "category": "wordsearch", "icon": "",
"name_pl": "Szybki", "name_ru": "Быстрый", "name_uk": "Швидкий", "name_en": "Fast",
"desc_pl": "Ukończ poziom w mniej niż 60 sekund", "desc_ru": "Пройти уровень быстрее 60 секунд", "desc_uk": "Пройти рівень швидше 60 секунд", "desc_en": "Complete a level in under 60 seconds",
"type": "speed_level", "value": 60},
{"code": "ws_speed_30", "category": "wordsearch", "icon": "🚀",
"name_pl": "Błyskawica", "name_ru": "Молния", "name_uk": "Блискавка", "name_en": "Lightning",
"desc_pl": "Ukończ poziom w mniej niż 30 sekund", "desc_ru": "Пройти уровень быстрее 30 секунд", "desc_uk": "Пройти рівень швидше 30 секунд", "desc_en": "Complete a level in under 30 seconds",
"type": "speed_level", "value": 30},
{"code": "perfect_5", "category": "wordsearch", "icon": "🎯",
"name_pl": "Snajper", "name_ru": "Снайпер", "name_uk": "Снайпер", "name_en": "Sniper",
"desc_pl": "Znajdź 5 słów bez błędu", "desc_ru": "Найти 5 слов подряд без ошибок", "desc_uk": "Знайти 5 слів без помилки", "desc_en": "Find 5 words without mistakes",
"type": "perfect_words", "value": 5},
# ===== Sentence Builder (10) =====
{"code": "first_sentence", "category": "sentence", "icon": "📝",
"name_pl": "Debiutant", "name_ru": "Дебютант", "name_uk": "Дебютант", "name_en": "Debutant",
"desc_pl": "Ułóż 1 zdanie", "desc_ru": "Составить 1 предложение", "desc_uk": "Скласти 1 речення", "desc_en": "Build 1 sentence",
"type": "sentences_completed", "value": 1},
{"code": "ten_sentences", "category": "sentence", "icon": "✍️",
"name_pl": "Autor", "name_ru": "Автор", "name_uk": "Автор", "name_en": "Author",
"desc_pl": "Ułóż 10 zdań", "desc_ru": "Составить 10 предложений", "desc_uk": "Скласти 10 речень", "desc_en": "Build 10 sentences",
"type": "sentences_completed", "value": 10},
{"code": "fifty_sentences", "category": "sentence", "icon": "🎓",
"name_pl": "Profesor", "name_ru": "Профессор", "name_uk": "Професор", "name_en": "Professor",
"desc_pl": "Ułóż 50 zdań", "desc_ru": "Составить 50 предложений", "desc_uk": "Скласти 50 речень", "desc_en": "Build 50 sentences",
"type": "sentences_completed", "value": 50},
{"code": "hundred_sentences", "category": "sentence", "icon": "📚",
"name_pl": "Bibliotekarz", "name_ru": "Библиотекарь", "name_uk": "Бібліотекар", "name_en": "Librarian",
"desc_pl": "Ułóż 100 zdań", "desc_ru": "Составить 100 предложений", "desc_uk": "Скласти 100 речень", "desc_en": "Build 100 sentences",
"type": "sentences_completed", "value": 100},
{"code": "sentence_200", "category": "sentence", "icon": "📖",
"name_pl": "Pisarz", "name_ru": "Писатель", "name_uk": "Письменник", "name_en": "Writer",
"desc_pl": "Ułóż 200 zdań", "desc_ru": "Составить 200 предложений", "desc_uk": "Скласти 200 речень", "desc_en": "Build 200 sentences",
"type": "sentences_completed", "value": 200},
{"code": "perfect_sentence_5", "category": "sentence", "icon": "",
"name_pl": "Perfekcjonista", "name_ru": "Перфекционист", "name_uk": "Перфекціоніст", "name_en": "Perfectionist",
"desc_pl": "Ułóż 5 zdań bez błędu", "desc_ru": "Составить 5 предложений без единой ошибки", "desc_uk": "Скласти 5 речень без жодної помилки", "desc_en": "Build 5 sentences without mistakes",
"type": "perfect_sentences", "value": 5},
{"code": "sentence_perfect_10", "category": "sentence", "icon": "💎",
"name_pl": "Mistrz zdań", "name_ru": "Мастер предложений", "name_uk": "Майстер речень", "name_en": "Sentence Master",
"desc_pl": "Ułóż 10 zdań bez błędu", "desc_ru": "Составить 10 предложений без ошибок", "desc_uk": "Скласти 10 речень без помилок", "desc_en": "Build 10 sentences without mistakes",
"type": "perfect_sentences", "value": 10},
{"code": "sentence_streak_10", "category": "sentence", "icon": "💪",
"name_pl": "Nieomylny", "name_ru": "Безошибочный", "name_uk": "Безпомилковий", "name_en": "Flawless",
"desc_pl": "Ułóż 10 zdań z rzędu bez błędu", "desc_ru": "Составить 10 предложений подряд без ошибок", "desc_uk": "Скласти 10 речень поспіль без помилок", "desc_en": "Build 10 sentences in a row without mistakes",
"type": "sentence_streak", "value": 10},
{"code": "speed_10", "category": "sentence", "icon": "🚀",
"name_pl": "Turbo", "name_ru": "Турбо", "name_uk": "Турбо", "name_en": "Turbo",
"desc_pl": "Ułóż zdanie w mniej niż 10 sekund", "desc_ru": "Составить предложение быстрее 10 секунд", "desc_uk": "Скласти речення швидше 10 секунд", "desc_en": "Build a sentence in under 10 seconds",
"type": "speed_sentence", "value": 10},
{"code": "sentence_speed_5", "category": "sentence", "icon": "",
"name_pl": "Ekspres", "name_ru": "Экспресс", "name_uk": "Експрес", "name_en": "Express",
"desc_pl": "Ułóż zdanie w mniej niż 5 sekund", "desc_ru": "Составить предложение быстрее 5 секунд", "desc_uk": "Скласти речення швидше 5 секунд", "desc_en": "Build a sentence in under 5 seconds",
"type": "speed_sentence", "value": 5},
# ===== Word Quiz (8) =====
{"code": "quiz_10", "category": "wordquiz", "icon": "🧠",
"name_pl": "Myśliciel", "name_ru": "Мыслитель", "name_uk": "Мислитель", "name_en": "Thinker",
"desc_pl": "Odgadnij 10 słów w Quiz słownym", "desc_ru": "Угадать 10 слов в Квизе слов", "desc_uk": "Відгадати 10 слів у Квізі слів", "desc_en": "Guess 10 words in Word Quiz",
"type": "quiz_guessed", "value": 10},
{"code": "quiz_50", "category": "wordquiz", "icon": "🎓",
"name_pl": "Mistrz quizu", "name_ru": "Мастер квиза", "name_uk": "Майстер квізу", "name_en": "Quiz Master",
"desc_pl": "Odgadnij 50 słów w Quiz słownym", "desc_ru": "Угадать 50 слов в Квизе слов", "desc_uk": "Відгадати 50 слів у Квізі слів", "desc_en": "Guess 50 words in Word Quiz",
"type": "quiz_guessed", "value": 50},
{"code": "quiz_100", "category": "wordquiz", "icon": "🏆",
"name_pl": "Mózg", "name_ru": "Мозг", "name_uk": "Мозок", "name_en": "Brain",
"desc_pl": "Odgadnij 100 słów w Quiz słownym", "desc_ru": "Угадать 100 слов в Квизе слов", "desc_uk": "Відгадати 100 слів у Квізі слів", "desc_en": "Guess 100 words in Word Quiz",
"type": "quiz_guessed", "value": 100},
{"code": "quiz_perfect_10", "category": "wordquiz", "icon": "💎",
"name_pl": "Idealny wynik", "name_ru": "Идеальный результат", "name_uk": "Ідеальний результат", "name_en": "Perfect Score",
"desc_pl": "Odgadnij 10 słów bez pomyłki", "desc_ru": "Угадать 10 слов без ошибок", "desc_uk": "Відгадати 10 слів без помилок", "desc_en": "Guess 10 words without mistakes",
"type": "quiz_perfect_streak", "value": 10},
{"code": "quiz_streak_20", "category": "wordquiz", "icon": "🔥",
"name_pl": "Niepowstrzymany", "name_ru": "Неудержимый", "name_uk": "Невтримний", "name_en": "Unstoppable",
"desc_pl": "Odgadnij 20 słów z rzędu", "desc_ru": "Угадать 20 слов подряд", "desc_uk": "Відгадати 20 слів поспіль", "desc_en": "Guess 20 words in a row",
"type": "quiz_perfect_streak", "value": 20},
{"code": "quiz_speed", "category": "wordquiz", "icon": "",
"name_pl": "Błyskawica", "name_ru": "Молния", "name_uk": "Блискавка", "name_en": "Lightning",
"desc_pl": "Odgadnij słowo w mniej niż 3 sekundy", "desc_ru": "Угадать слово быстрее 3 секунд", "desc_uk": "Відгадати слово швидше 3 секунд", "desc_en": "Guess a word in under 3 seconds",
"type": "quiz_speed", "value": 3},
{"code": "quiz_speed_2", "category": "wordquiz", "icon": "🚀",
"name_pl": "Super szybki", "name_ru": "Супер быстрый", "name_uk": "Супер швидкий", "name_en": "Super Fast",
"desc_pl": "Odgadnij słowo w mniej niż 2 sekundy", "desc_ru": "Угадать слово быстрее 2 секунд", "desc_uk": "Відгадати слово швидше 2 секунд", "desc_en": "Guess a word in under 2 seconds",
"type": "quiz_speed", "value": 2},
{"code": "quiz_no_mistakes", "category": "wordquiz", "icon": "🏆",
"name_pl": "Bezbłędny", "name_ru": "Безошибочный", "name_uk": "Безпомилковий", "name_en": "Flawless",
"desc_pl": "Odgadnij słowo bez żadnej pomyłki", "desc_ru": "Угадать слово без единой ошибки", "desc_uk": "Відгадати слово без жодної помилки", "desc_en": "Guess a word without any mistakes",
"type": "quiz_no_mistakes", "value": 1},
# ===== Bubble Words (8) =====
{"code": "bubble_5", "category": "bubblewords", "icon": "🫧",
"name_pl": "Początkujący", "name_ru": "Начинающий", "name_uk": "Початківець", "name_en": "Beginner",
"desc_pl": "Ukończ 5 poziomów w Bąbelkach słów", "desc_ru": "Пройти 5 уровней в Пузырях слов", "desc_uk": "Пройти 5 рівнів у Бульбашках слів", "desc_en": "Complete 5 levels in Bubble Words",
"type": "bubble_levels", "value": 5},
{"code": "bubble_25", "category": "bubblewords", "icon": "🌟",
"name_pl": "Bąbelkowy mistrz", "name_ru": "Пузырьковый мастер", "name_uk": "Бульбашковий майстер", "name_en": "Bubble Master",
"desc_pl": "Ukończ 25 poziomów w Bąbelkach słów", "desc_ru": "Пройти 25 уровней в Пузырях слов", "desc_uk": "Пройти 25 рівнів у Бульбашках слів", "desc_en": "Complete 25 levels in Bubble Words",
"type": "bubble_levels", "value": 25},
{"code": "bubble_50", "category": "bubblewords", "icon": "💪",
"name_pl": "Bańkowy wojownik", "name_ru": "Пузырьковый воин", "name_uk": "Бульбашковий воїн", "name_en": "Bubble Warrior",
"desc_pl": "Ukończ 50 poziomów w Bąbelkach słów", "desc_ru": "Пройти 50 уровней в Пузырях слов", "desc_uk": "Пройти 50 рівнів у Бульбашках слів", "desc_en": "Complete 50 levels in Bubble Words",
"type": "bubble_levels", "value": 50},
{"code": "bubble_100", "category": "bubblewords", "icon": "👑",
"name_pl": "Król bąbelków", "name_ru": "Король пузырей", "name_uk": "Король бульбашок", "name_en": "Bubble King",
"desc_pl": "Ukończ 100 poziomów w Bąbelkach słów", "desc_ru": "Пройти 100 уровней в Пузырях слов", "desc_uk": "Пройти 100 рівнів у Бульбашках слів", "desc_en": "Complete 100 levels in Bubble Words",
"type": "bubble_levels", "value": 100},
{"code": "bubble_no_hints", "category": "bubblewords", "icon": "💡",
"name_pl": "Samodzielny", "name_ru": "Самостоятельный", "name_uk": "Самостійний", "name_en": "Independent",
"desc_pl": "Ukończ poziom bez podpowiedzi", "desc_ru": "Пройти уровень без подсказок", "desc_uk": "Пройти рівень без підказок", "desc_en": "Complete a level without hints",
"type": "bubble_no_hints", "value": 1},
{"code": "bubble_perfect", "category": "bubblewords", "icon": "",
"name_pl": "Perfekcyjny", "name_ru": "Идеальный", "name_uk": "Ідеальний", "name_en": "Perfect",
"desc_pl": "Ukończ poziom z maksymalną punktacją (100)", "desc_ru": "Пройти уровень с максимальным счётом (100)", "desc_uk": "Пройти рівень з максимальним рахунком (100)", "desc_en": "Complete a level with maximum score (100)",
"type": "bubble_perfect", "value": 1},
{"code": "bubble_speed", "category": "bubblewords", "icon": "🚀",
"name_pl": "Szybki bańki", "name_ru": "Быстрые пузыри", "name_uk": "Швидкі бульбашки", "name_en": "Fast Bubbles",
"desc_pl": "Ukończ poziom w mniej niż 45 sekund", "desc_ru": "Пройти уровень быстрее 45 секунд", "desc_uk": "Пройти рівень швидше 45 секунд", "desc_en": "Complete a level in under 45 seconds",
"type": "bubble_speed", "value": 45},
{"code": "bubble_speed_30", "category": "bubblewords", "icon": "",
"name_pl": "Bąbelkowa błyskawica", "name_ru": "Пузырьковая молния", "name_uk": "Бульбашкова блискавка", "name_en": "Bubble Lightning",
"desc_pl": "Ukończ poziom w mniej niż 30 sekund", "desc_ru": "Пройти уровень быстрее 30 секунд", "desc_uk": "Пройти рівень швидше 30 секунд", "desc_en": "Complete a level in under 30 seconds",
"type": "bubble_speed", "value": 30},
# ===== General (15) =====
{"code": "score_1000", "category": "general", "icon": "🌟",
"name_pl": "Wschodząca gwiazda", "name_ru": "Восходящая звезда", "name_uk": "Висхідна зірка", "name_en": "Rising Star",
"desc_pl": "Zdobądź 1000 punktów", "desc_ru": "Набрать 1000 очков", "desc_uk": "Набрати 1000 балів", "desc_en": "Earn 1000 points",
"type": "total_score", "value": 1000},
{"code": "score_5000", "category": "general", "icon": "💎",
"name_pl": "Diament", "name_ru": "Бриллиант", "name_uk": "Діамант", "name_en": "Diamond",
"desc_pl": "Zdobądź 5000 punktów", "desc_ru": "Набрать 5000 очков", "desc_uk": "Набрати 5000 балів", "desc_en": "Earn 5000 points",
"type": "total_score", "value": 5000},
{"code": "score_10000", "category": "general", "icon": "💯",
"name_pl": "Legenda", "name_ru": "Легенда", "name_uk": "Легенда", "name_en": "Legend",
"desc_pl": "Zdobądź 10000 punktów", "desc_ru": "Набрать 10000 очков", "desc_uk": "Набрати 10000 балів", "desc_en": "Earn 10000 points",
"type": "total_score", "value": 10000},
{"code": "score_25000", "category": "general", "icon": "🦄",
"name_pl": "Jednorożec", "name_ru": "Единорог", "name_uk": "Єдиноріг", "name_en": "Unicorn",
"desc_pl": "Zdobądź 25000 punktów", "desc_ru": "Набрать 25000 очков", "desc_uk": "Набрати 25000 балів", "desc_en": "Earn 25000 points",
"type": "total_score", "value": 25000},
{"code": "rank_1", "category": "general", "icon": "🏆",
"name_pl": "Mistrz", "name_ru": "Чемпион", "name_uk": "Чемпіон", "name_en": "Champion",
"desc_pl": "Zajmij 1 miejsce w rankingu", "desc_ru": "Занять 1 место в рейтинге", "desc_uk": "Зайняти 1 місце в рейтингу", "desc_en": "Reach 1st place on leaderboard",
"type": "rank_reached", "value": 1},
{"code": "rank_top3", "category": "general", "icon": "🥇",
"name_pl": "Medalista", "name_ru": "Призёр", "name_uk": "Призер", "name_en": "Medalist",
"desc_pl": "Zajmij miejsce w top 3", "desc_ru": "Попасть в топ-3 рейтинга", "desc_uk": "Потрапити в топ-3 рейтингу", "desc_en": "Reach top 3 on leaderboard",
"type": "rank_reached", "value": 3},
{"code": "login_3", "category": "general", "icon": "📊",
"name_pl": "Stały gracz", "name_ru": "Постоянный игрок", "name_uk": "Постійний гравець", "name_en": "Regular Player",
"desc_pl": "Zaloguj się 3 dni z rzędu", "desc_ru": "Заходить 3 дня подряд", "desc_uk": "Заходити 3 дні поспіль", "desc_en": "Log in 3 days in a row",
"type": "login_streak", "value": 3},
{"code": "login_7", "category": "general", "icon": "🔥",
"name_pl": "Tygodniowa seria", "name_ru": "Недельная серия", "name_uk": "Тижнева серія", "name_en": "Weekly Streak",
"desc_pl": "Zaloguj się 7 dni z rzędu", "desc_ru": "Заходить 7 дней подряд", "desc_uk": "Заходити 7 днів поспіль", "desc_en": "Log in 7 days in a row",
"type": "login_streak", "value": 7},
{"code": "change_language", "category": "general", "icon": "🌍",
"name_pl": "Poliglota", "name_ru": "Полиглот", "name_uk": "Поліглот", "name_en": "Polyglot",
"desc_pl": "Zmień język interfejsu", "desc_ru": "Сменить язык интерфейса", "desc_uk": "Змінити мову інтерфейсу", "desc_en": "Change interface language",
"type": "language_changed", "value": 1},
# 🔧 ИСПРАВЛЕНО: Заменено достижение "Открой Грамматику" на "Открой меню учителя"
{"code": "open_teacher_menu", "category": "general", "icon": "🎓",
"name_pl": "Uczeń", "name_ru": "Ученик", "name_uk": "Учень", "name_en": "Student",
"desc_pl": "Otwórz menu nauczyciela", "desc_ru": "Открыть меню учителя", "desc_uk": "Відкрити меню вчителя", "desc_en": "Open the teacher menu",
"type": "teacher_menu_opened", "value": 1},
{"code": "open_cheatsheets", "category": "general", "icon": "📝",
"name_pl": "Ciekawski", "name_ru": "Любознательный", "name_uk": "Допитливий", "name_en": "Curious",
"desc_pl": "Otwórz sekcję Ściągi", "desc_ru": "Открыть раздел Шпаргалки", "desc_uk": "Відкрити розділ Шпаргалки", "desc_en": "Open the Cheatsheets section",
"type": "cheatsheets_opened", "value": 1},
{"code": "open_pronunciation", "category": "general", "icon": "🔊",
"name_pl": "Słuchacz", "name_ru": "Слушатель", "name_uk": "Слухач", "name_en": "Listener",
"desc_pl": "Otwórz sekcję Wymowa", "desc_ru": "Открыть раздел Произношение", "desc_uk": "Відкрити розділ Вимова", "desc_en": "Open the Pronunciation section",
"type": "pronunciation_opened", "value": 1},
{"code": "open_ai_assistant", "category": "general", "icon": "🤖",
"name_pl": "Asystent AI", "name_ru": "AI-помощник", "name_uk": "AI-помічник", "name_en": "AI Assistant",
"desc_pl": "Otwórz Asystenta AI", "desc_ru": "Открыть AI-помощника", "desc_uk": "Відкрити AI-помічника", "desc_en": "Open the AI Assistant",
"type": "ai_assistant_opened", "value": 1},
{"code": "open_alphabet", "category": "general", "icon": "🔤",
"name_pl": "Alfabetysta", "name_ru": "Алфавитист", "name_uk": "Алфавітист", "name_en": "Alphabetist",
"desc_pl": "Otwórz sekcję Alfabet", "desc_ru": "Открыть раздел Алфавит", "desc_uk": "Відкрити розділ Алфавіт", "desc_en": "Open the Alphabet section",
"type": "alphabet_opened", "value": 1},
{"code": "open_rules", "category": "general", "icon": "📐",
"name_pl": "Gramatyk", "name_ru": "Грамматик", "name_uk": "Граматик", "name_en": "Grammarian",
"desc_pl": "Otwórz sekcję Zasady", "desc_ru": "Открыть раздел Правила", "desc_uk": "Відкрити розділ Правила", "desc_en": "Open the Rules section",
"type": "rules_opened", "value": 1},
# 🔧 НОВОЕ: Достижение за открытие Прогресса
{"code": "open_progress", "category": "general", "icon": "📊",
"name_pl": "Planista", "name_ru": "Планировщик", "name_uk": "Планувальник", "name_en": "Planner",
"desc_pl": "Otwórz sekcję Postęp", "desc_ru": "Открыть раздел Прогресс", "desc_uk": "Відкрити розділ Прогрес", "desc_en": "Open the Progress section",
"type": "progress_opened", "value": 1},
]
async def init_achievements():
"""Добавляет новые достижения, не трогая существующие"""
pool = await get_pool()
async with pool.acquire() as conn:
for a in ACHIEVEMENTS_DATA:
exists = await conn.fetchval(
"SELECT 1 FROM achievements WHERE code = $1", a["code"]
)
if not exists:
await conn.execute(
"""INSERT INTO achievements (code, category, icon,
name_pl, name_ru, name_uk, name_en,
description_pl, description_ru, description_uk, description_en,
requirement_type, requirement_value)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)""",
a["code"], a["category"], a["icon"],
a["name_pl"], a["name_ru"], a["name_uk"], a["name_en"],
a["desc_pl"], a["desc_ru"], a["desc_uk"], a["desc_en"],
a["type"], a["value"]
)
else:
# 🔧 НОВОЕ: Обновляем существующие достижения (на случай изменения названий)
await conn.execute("""
UPDATE achievements SET
category = $2,
icon = $3,
name_pl = $4,
name_ru = $5,
name_uk = $6,
name_en = $7,
description_pl = $8,
description_ru = $9,
description_uk = $10,
description_en = $11,
requirement_type = $12,
requirement_value = $13
WHERE code = $1
""", a["code"], a["category"], a["icon"],
a["name_pl"], a["name_ru"], a["name_uk"], a["name_en"],
a["desc_pl"], a["desc_ru"], a["desc_uk"], a["desc_en"],
a["type"], a["value"])
@router.get("")
async def get_all_achievements(request: Request):
"""Получить все достижения"""
await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM achievements ORDER BY id")
return {
"achievements": [dict(row) for row in rows]
}
@router.get("/user")
async def get_user_achievements(request: Request):
"""Получить достижения пользователя"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT a.*,
CASE WHEN ua.user_id IS NOT NULL THEN TRUE ELSE FALSE END as unlocked,
ua.unlocked_at
FROM achievements a
LEFT JOIN user_achievements ua ON a.id = ua.achievement_id AND ua.user_id = $1
ORDER BY a.id
""", telegram_id)
achievements = []
for row in rows:
a = dict(row)
if a.get("unlocked_at"):
a["unlocked_at"] = a["unlocked_at"].isoformat()
achievements.append(a)
total = len(achievements)
unlocked = sum(1 for a in achievements if a["unlocked"])
return {
"achievements": achievements,
"total": total,
"unlocked": unlocked,
"progress_percent": round((unlocked / total * 100), 1) if total > 0 else 0
}
async def check_and_award_achievements(telegram_id: int, achievement_type: str, value: int = 1):
"""
Проверяет и выдаёт достижения пользователю.
Возвращает названия на языке пользователя.
"""
pool = await get_pool()
async with pool.acquire() as conn:
user_lang = await conn.fetchval(
"SELECT app_language FROM users WHERE telegram_id = $1", telegram_id
)
if not user_lang or user_lang not in ["pl", "ru", "uk", "en"]:
user_lang = "pl"
matching = await conn.fetch("""
SELECT a.* FROM achievements a
WHERE a.requirement_type = $1
AND a.requirement_value <= $2
AND a.id NOT IN (
SELECT achievement_id FROM user_achievements WHERE user_id = $3
)
""", achievement_type, value, telegram_id)
newly_unlocked = []
name_field = f"name_{user_lang}"
for ach in matching:
await conn.execute(
"INSERT INTO user_achievements (user_id, achievement_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
telegram_id, ach["id"]
)
newly_unlocked.append({
"id": ach["id"],
"code": ach["code"],
"icon": ach["icon"],
"name": ach[name_field],
})
return newly_unlocked
@router.post("/track-login")
async def track_login(request: Request):
"""Отмечает ежедневный вход пользователя и проверяет достижения серии"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"INSERT INTO daily_logins (user_id, login_date) VALUES ($1, CURRENT_DATE) ON CONFLICT DO NOTHING",
telegram_id
)
streak = await conn.fetchval("""
WITH dates AS (
SELECT login_date,
login_date - ROW_NUMBER() OVER (ORDER BY login_date DESC)::int AS grp
FROM daily_logins
WHERE user_id = $1
ORDER BY login_date DESC
)
SELECT COUNT(*) FROM dates WHERE grp = (SELECT grp FROM dates LIMIT 1)
""", telegram_id)
streak = streak or 0
new_achievements = await check_and_award_achievements(telegram_id, "login_streak", streak)
return {
"login_streak": streak,
"new_achievements": new_achievements
}
# 🔧 НОВОЕ: Трекинг открытия меню учителя
@router.post("/track-teacher-menu")
async def track_teacher_menu_opened(request: Request):
"""Отмечает открытие меню учителя и выдаёт достижение"""
telegram_id, _ = await require_auth(request)
new_achievements = await check_and_award_achievements(telegram_id, "teacher_menu_opened", 1)
return {
"new_achievements": new_achievements
}
# 🔧 НОВОЕ: Трекинг открытия Прогресса
@router.post("/track-progress")
async def track_progress_opened(request: Request):
"""Отмечает открытие раздела Прогресс и выдаёт достижение"""
telegram_id, _ = await require_auth(request)
new_achievements = await check_and_award_achievements(telegram_id, "progress_opened", 1)
return {
"new_achievements": new_achievements
}
@router.post("/track-cheatsheets")
async def track_cheatsheets_opened(request: Request):
"""Отмечает открытие раздела Шпаргалки и выдаёт достижение"""
telegram_id, _ = await require_auth(request)
new_achievements = await check_and_award_achievements(telegram_id, "cheatsheets_opened", 1)
return {
"new_achievements": new_achievements
}
@router.post("/track-pronunciation")
async def track_pronunciation_opened(request: Request):
"""Отмечает открытие раздела Произношение и выдаёт достижение"""
telegram_id, _ = await require_auth(request)
new_achievements = await check_and_award_achievements(telegram_id, "pronunciation_opened", 1)
return {
"new_achievements": new_achievements
}
@router.post("/track-ai-assistant")
async def track_ai_assistant_opened(request: Request):
"""Отмечает открытие AI-помощника и выдаёт достижение"""
telegram_id, _ = await require_auth(request)
new_achievements = await check_and_award_achievements(telegram_id, "ai_assistant_opened", 1)
return {
"new_achievements": new_achievements
}
@router.post("/track-alphabet")
async def track_alphabet_opened(request: Request):
"""Отмечает открытие раздела Алфавит и выдаёт достижение"""
telegram_id, _ = await require_auth(request)
new_achievements = await check_and_award_achievements(telegram_id, "alphabet_opened", 1)
return {
"new_achievements": new_achievements
}
@router.post("/track-rules")
async def track_rules_opened(request: Request):
"""Отмечает открытие раздела Правила и выдаёт достижение"""
telegram_id, _ = await require_auth(request)
new_achievements = await check_and_award_achievements(telegram_id, "rules_opened", 1)
return {
"new_achievements": new_achievements
}
+148
View File
@@ -0,0 +1,148 @@
"""
Admin Router — админ-панель с реальной статистикой проекта.
1. /admin/dashboard — общая статистика (юзеры, онлайн, по уровням)
2. /admin/sentry-errors — ошибки из Sentry (через API)
"""
import os
import logging
import httpx
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
from config import ADMIN_IDS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"])
# ============ Настройки Sentry ============
SENTRY_AUTH_TOKEN = os.getenv("SENTRY_AUTH_TOKEN", "")
SENTRY_ORG_SLUG = os.getenv("SENTRY_ORG_SLUG", "drobysh")
SENTRY_PROJECT_SLUG = os.getenv("SENTRY_PROJECT_SLUG", "filwords-backend")
SENTRY_API_BASE = os.getenv("SENTRY_API_BASE", "https://de.sentry.io/api/0")
def _require_admin(telegram_id: int):
"""Проверка, что пользователь — админ."""
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
@router.get("/dashboard")
async def get_admin_dashboard(request: Request):
"""
Общая статистика проекта:
- Всего пользователей / новых сегодня / онлайн
- Активных за 24 часа
- Распределение по уровням языка
"""
telegram_id, _ = await require_auth(request)
_require_admin(telegram_id)
pool = await get_pool()
async with pool.acquire() as conn:
# Всего пользователей
total_users = await conn.fetchval("SELECT COUNT(*) FROM users") or 0
# Новых сегодня
new_today = await conn.fetchval(
"SELECT COUNT(*) FROM users WHERE created_at::date = CURRENT_DATE"
) or 0
# Онлайн за последние 30 минут
online_now = await conn.fetchval("""
SELECT COUNT(DISTINCT user_id) FROM (
SELECT user_id FROM user_progress WHERE completed_at > NOW() - INTERVAL '30 minutes'
UNION
SELECT user_id FROM sentence_progress WHERE completed_at > NOW() - INTERVAL '30 minutes'
UNION
SELECT user_id FROM daily_logins WHERE login_date = CURRENT_DATE
) active
""") or 0
# Активных за 24 часа
active_24h = await conn.fetchval("""
SELECT COUNT(DISTINCT user_id) FROM (
SELECT user_id FROM user_progress WHERE completed_at > NOW() - INTERVAL '24 hours'
UNION
SELECT user_id FROM sentence_progress WHERE completed_at > NOW() - INTERVAL '24 hours'
UNION
SELECT user_id FROM user_learning_answers WHERE created_at > NOW() - INTERVAL '24 hours'
UNION
SELECT user_id FROM daily_logins WHERE login_date = CURRENT_DATE
) active
""") or 0
# Распределение по уровням
level_counts = {}
for lvl in ["A1", "A2", "B1", "B2", "C1", "C2"]:
count = await conn.fetchval(
"SELECT COUNT(*) FROM users WHERE current_level = $1", lvl
) or 0
level_counts[lvl] = count
return {
"total_users": total_users,
"new_today": new_today,
"online_now": online_now,
"active_24h": active_24h,
"level_counts": level_counts,
}
@router.get("/sentry-errors")
async def get_sentry_errors(request: Request):
"""
Последние ошибки из Sentry.
Возвращает только unresolved (нерешённые) за последние 24 часа.
"""
telegram_id, _ = await require_auth(request)
_require_admin(telegram_id)
if not SENTRY_AUTH_TOKEN:
return {"errors": [], "error": "SENTRY_AUTH_TOKEN not configured"}
url = f"{SENTRY_API_BASE}/projects/{SENTRY_ORG_SLUG}/{SENTRY_PROJECT_SLUG}/issues/"
params = {
"query": "is:unresolved",
"statsPeriod": "24h",
"limit": 20,
}
headers = {
"Authorization": f"Bearer {SENTRY_AUTH_TOKEN}",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, params=params, headers=headers)
if response.status_code != 200:
logger.error(f"Sentry API error: {response.status_code}{response.text[:200]}")
return {"errors": [], "error": f"Sentry API: {response.status_code}"}
raw_issues = response.json()
errors = []
for issue in raw_issues:
errors.append({
"id": issue.get("id"),
"short_id": issue.get("shortId"),
"title": issue.get("title"),
"culprit": issue.get("culprit"),
"level": issue.get("level"),
"count": int(issue.get("count", 0)),
"user_count": issue.get("userCount", 0),
"first_seen": issue.get("firstSeen"),
"last_seen": issue.get("lastSeen"),
"permalink": issue.get("permalink"),
"priority": issue.get("priority"),
})
return {"errors": errors, "count": len(errors)}
except httpx.TimeoutException:
logger.error("Sentry API timeout")
return {"errors": [], "error": "Sentry API timeout"}
except Exception as e:
logger.error(f"Sentry API unexpected error: {e}")
return {"errors": [], "error": str(e)}
File diff suppressed because it is too large Load Diff
+480
View File
@@ -0,0 +1,480 @@
"""
AI Assistant Router — чат-помощник с историей, контекстом, лимитами и статистикой.
Лёгкие запросы → GPT-OSS 20B, сложные → GPT-OSS 120B.
Бесплатный лимит: 5 сообщений в день (кроме админов).
"""
import logging
import time
import json
import httpx
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from config import TOGETHER_API_KEY, ADMIN_IDS
from database import get_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/assistant", tags=["assistant"])
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
# Модели
EASY_MODEL = "openai/gpt-oss-20b"
HARD_MODEL = "openai/gpt-oss-120b"
# Максимум сообщений в истории (10 последних)
MAX_HISTORY = 10
# Бесплатный лимит сообщений в день
FREE_DAILY_LIMIT = 5
LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]
SYSTEM_PROMPT = """Ты — AI-помощник Filwords PL, приложения для изучения польского языка.
Твои задачи:
1. Переводить фразы с русского/украинского/английского на польский и обратно
2. Объяснять грамматику польского языка простыми словами
3. Отвечать на вопросы о польском языке
4. Давать примеры использования слов и фраз
5. Помогать с повседневными фразами (магазин, врач, работа, учёба)
ОПРЕДЕЛЕНИЕ ЯЗЫКА ПОЛЬЗОВАТЕЛЯ:
- Если пользователь пишет на русском — отвечай на русском
- Если на украинском — отвечай на украинском
- Если на польском — отвечай на польском
- Если на английском — отвечай на английском
- ВСЕГДА отвечай на языке пользователя!
- Польские слова и фразы в примерах оставляй на польском, но объяснения и комментарии — на языке пользователя
ВАЖНЫЕ ПРАВИЛА:
- НЕ используй markdown (никаких **, *, #, `, ```)
- Пиши простым текстом
- Для перевода используй формат: Польская фраза → Перевод
- Будь кратким, но полезным
- Отвечай на языке пользователя
- Не упоминай, что ты AI или модель. Ты — помощник Filwords."""
# Ключевые слова для сложных запросов (на 4 языках)
HARD_KEYWORDS = [
# Русские
"объясни", "почему", "грамматик", "правило", "разница", "расскажи",
"подробно", "история", "примеры", "спряжение", "склонение", "падеж",
"урок", "мини-урок", "занятие", "обучение",
# Украинские
"поясни", "чому", "граматик", "правило", "різниця", "розкажи",
"докладно", "історія", "приклади", "відмінювання", "відмінок",
"урок", "міні-урок", "заняття", "навчання",
# Польские
"wytłumacz", "dlaczego", "gramatyk", "zasada", "różnica", "opowiedz",
"szczegółowo", "historia", "przykłady", "koniugacja", "deklinacja", "przypadek",
"lekcja", "mini-lekcja", "zajęcia", "nauka",
# Английские
"explain", "why", "grammar", "rule", "difference", "tell me",
"detailed", "history", "examples", "conjugation", "declension", "case",
"lesson", "mini-lesson", "class", "learning",
]
# Ключевые слова для лёгких запросов
EASY_KEYWORDS = [
# Русские
"переведи", "как сказать", "что значит", "привет", "спасибо", "пока",
# Украинские
"переклади", "як сказати", "що означає", "привіт", "дякую", "бувай",
# Польские
"przetłumacz", "jak powiedzieć", "co znaczy", "cześć", "dziękuję",
# Английские
"translate", "how to say", "what does", "hello", "thank you", "bye",
]
def get_model_for_message(message: str) -> str:
"""Определяет какая модель нужна: лёгкая или сложная"""
msg_lower = message.lower()
if any(kw in msg_lower for kw in HARD_KEYWORDS):
logger.info(f"Using HARD model for message: {message[:50]}...")
return HARD_MODEL
if len(message) > 150:
logger.info(f"Using HARD model (long message, {len(message)} chars)")
return HARD_MODEL
logger.info(f"Using EASY model for message: {message[:50]}...")
return EASY_MODEL
def clean_response(text: str) -> str:
"""Убирает markdown-символы из ответа"""
text = text.replace("**", "").replace("*", "")
text = text.replace("###", "").replace("##", "").replace("#", "")
text = text.replace("```", "").replace("`", "")
return text.strip()
async def save_message(user_id: int, message: str, is_user: bool):
"""Сохраняет сообщение в БД и удаляет старые (оставляет только 10 последних)"""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""INSERT INTO chat_history (user_id, message, is_user) VALUES ($1, $2, $3)""",
user_id, message, is_user
)
await conn.execute("""
DELETE FROM chat_history
WHERE user_id = $1
AND id NOT IN (
SELECT id FROM chat_history
WHERE user_id = $1
ORDER BY id DESC
LIMIT $2
)
""", user_id, MAX_HISTORY)
async def get_history(user_id: int) -> list:
"""Получает историю чата (последние 10 сообщений)"""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT message, is_user FROM chat_history
WHERE user_id = $1
ORDER BY id ASC
""", user_id)
return [{"role": "user" if row["is_user"] else "assistant", "content": row["message"]} for row in rows]
async def get_daily_usage(user_id: int) -> int:
"""Получает количество сообщений за сегодня"""
pool = await get_pool()
async with pool.acquire() as conn:
count = await conn.fetchval("""
SELECT message_count FROM chat_usage
WHERE user_id = $1 AND usage_date = CURRENT_DATE
""", user_id)
return count or 0
async def increment_daily_usage(user_id: int):
"""Увеличивает счётчик сообщений за сегодня"""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO chat_usage (user_id, usage_date, message_count)
VALUES ($1, CURRENT_DATE, 1)
ON CONFLICT (user_id, usage_date)
DO UPDATE SET message_count = chat_usage.message_count + 1
""", user_id)
@router.post("/chat")
async def chat(request: Request):
"""Принимает сообщение пользователя и возвращает ответ AI"""
telegram_id, _ = await require_auth(request)
if not TOGETHER_API_KEY:
raise HTTPException(500, "AI API key not configured")
body = await request.json()
user_message = body.get("message", "").strip()
if not user_message:
raise HTTPException(400, "Message is required")
if len(user_message) > 500:
raise HTTPException(400, "Message too long (max 500 characters)")
is_admin = telegram_id in ADMIN_IDS
if not is_admin:
daily_usage = await get_daily_usage(telegram_id)
if daily_usage >= FREE_DAILY_LIMIT:
return {
"limit_exceeded": True,
"daily_limit": FREE_DAILY_LIMIT,
"used": daily_usage,
"remaining": 0,
"response": "Dziś wykorzystałeś już wszystkie darmowe zapytania. Wróć jutro!",
}
await save_message(telegram_id, user_message, True)
if not is_admin:
await increment_daily_usage(telegram_id)
history = await get_history(telegram_id)
model = get_model_for_message(user_message)
logger.info(f"Assistant chat: user={telegram_id}, model={model}, msg_len={len(user_message)}, history={len(history)}")
start_time = time.time()
try:
async with httpx.AsyncClient(timeout=45.0) as client:
response = await client.post(
TOGETHER_API_URL,
headers={
"Authorization": f"Bearer {TOGETHER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
*history,
],
"max_tokens": 2500,
"temperature": 0.7,
},
)
elapsed_time = time.time() - start_time
logger.info(f"Assistant response: status={response.status_code}, time={elapsed_time:.2f}s")
if response.status_code != 200:
logger.error(f"AI API error: {response.status_code}, body={response.text[:200]}")
raise HTTPException(500, f"AI API error: {response.status_code}")
data = response.json()
ai_response = data["choices"][0]["message"]["content"].strip()
ai_response = clean_response(ai_response)
await save_message(telegram_id, ai_response, False)
remaining = FREE_DAILY_LIMIT if is_admin else max(0, FREE_DAILY_LIMIT - await get_daily_usage(telegram_id))
logger.info(f"Assistant answer: len={len(ai_response)}, model={model}")
return {
"response": ai_response,
"model_used": model,
"limit_exceeded": False,
"daily_limit": FREE_DAILY_LIMIT if not is_admin else None,
"remaining": remaining if not is_admin else None,
}
except HTTPException:
raise
except httpx.TimeoutException:
logger.error(f"AI API timeout after {time.time() - start_time:.2f}s")
raise HTTPException(500, "AI service timeout")
except Exception as e:
logger.error(f"AI service error: {str(e)}")
raise HTTPException(500, f"AI service error: {str(e)}")
@router.post("/save-analytics")
async def save_analytics(request: Request):
"""Сохраняет ответ аналитики в историю чата"""
telegram_id, _ = await require_auth(request)
body = await request.json()
message = body.get("message", "").strip()
if not message:
raise HTTPException(400, "Message is required")
await save_message(telegram_id, message, False)
return {"status": "ok"}
@router.get("/stats/personal")
async def get_personal_stats(request: Request):
"""Возвращает личную статистику пользователя для AI"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow("SELECT current_level, app_language FROM users WHERE telegram_id = $1", telegram_id)
if not user:
raise HTTPException(401, "User not registered")
current_level = user["current_level"] or "A1"
# Прогресс по каждому уровню
level_progress = {}
total_score = 0
total_achievements = await conn.fetchval("""
SELECT COUNT(*) FROM user_achievements WHERE user_id = $1
""", telegram_id) or 0
for level in LEVELS:
# Филворды
ws_total = await conn.fetchval("SELECT COUNT(*) FROM levels WHERE level = $1", level) or 0
ws_completed = await conn.fetchval(
"SELECT COUNT(DISTINCT level_id) FROM user_progress WHERE user_id = $1 AND level = $2 AND completed_at IS NOT NULL",
telegram_id, level
) or 0
ws_percent = round((ws_completed / ws_total * 100), 1) if ws_total > 0 else 0
# Предложения
s_total = await conn.fetchval("SELECT COUNT(*) FROM sentences WHERE level = $1", level) or 0
s_completed = await conn.fetchval(
"SELECT COUNT(*) FROM sentence_progress WHERE user_id = $1 AND level = $2 AND completed_at IS NOT NULL",
telegram_id, level
) or 0
s_percent = round((s_completed / s_total * 100), 1) if s_total > 0 else 0
# Очки уровня
ws_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM user_progress WHERE user_id = $1 AND level = $2 AND completed_at IS NOT NULL",
telegram_id, level
) or 0
s_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1 AND level = $2 AND completed_at IS NOT NULL",
telegram_id, level
) or 0
wq_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM word_quiz_progress WHERE user_id = $1 AND level = $2",
telegram_id, level
) or 0
bw_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM bubble_words_progress WHERE user_id = $1 AND level = $2",
telegram_id, level
) or 0
level_total = ws_score + s_score + wq_score + bw_score
total_score += level_total
level_progress[level] = {
"wordsearch_percent": ws_percent,
"wordsearch_completed": ws_completed,
"wordsearch_total": ws_total,
"sentence_percent": s_percent,
"sentence_completed": s_completed,
"sentence_total": s_total,
"total_score": level_total,
}
# Квиз и пузыри общие
wq_words = await conn.fetchval(
"SELECT COALESCE(words_guessed, 0) FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
bw_levels = await conn.fetchval(
"SELECT COALESCE(levels_completed, 0) FROM bubble_words_progress WHERE user_id = $1",
telegram_id
) or 0
# Уроки
completed_topics = await conn.fetchval(
"SELECT COUNT(*) FROM user_topic_progress WHERE user_id = $1 AND test_completed = TRUE",
telegram_id
) or 0
return {
"current_level": current_level,
"total_score": total_score,
"achievements": total_achievements,
"words_guessed": wq_words,
"bubble_levels": bw_levels,
"completed_topics": completed_topics,
"level_progress": level_progress,
}
@router.get("/stats/admin")
async def get_admin_stats(request: Request):
"""Возвращает админ-статистику"""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
pool = await get_pool()
async with pool.acquire() as conn:
total_users = await conn.fetchval("SELECT COUNT(*) FROM users") or 0
new_today = await conn.fetchval("SELECT COUNT(*) FROM users WHERE created_at::date = CURRENT_DATE") or 0
online_now = await conn.fetchval("""
SELECT COUNT(DISTINCT user_id) FROM (
SELECT user_id FROM user_progress WHERE completed_at > NOW() - INTERVAL '30 minutes'
UNION
SELECT user_id FROM sentence_progress WHERE completed_at > NOW() - INTERVAL '30 minutes'
UNION
SELECT user_id FROM daily_logins WHERE login_date = CURRENT_DATE
) active
""") or 0
# Языки
language_counts = {}
for lang in ["pl", "ru", "uk", "en"]:
count = await conn.fetchval("SELECT COUNT(*) FROM users WHERE app_language = $1", lang) or 0
language_counts[lang] = count
# Уровни
level_counts = {}
for level in LEVELS:
count = await conn.fetchval("SELECT COUNT(*) FROM users WHERE current_level = $1", level) or 0
level_counts[level] = count
return {
"total_users": total_users,
"new_today": new_today,
"online_now": online_now,
"language_counts": language_counts,
"level_counts": level_counts,
}
@router.get("/limit")
async def get_limit(request: Request):
"""Возвращает информацию о лимите пользователя"""
telegram_id, _ = await require_auth(request)
is_admin = telegram_id in ADMIN_IDS
if is_admin:
return {
"is_admin": True,
"daily_limit": None,
"used": 0,
"remaining": None,
}
daily_usage = await get_daily_usage(telegram_id)
remaining = max(0, FREE_DAILY_LIMIT - daily_usage)
return {
"is_admin": False,
"daily_limit": FREE_DAILY_LIMIT,
"used": daily_usage,
"remaining": remaining,
}
@router.get("/history")
async def get_chat_history(request: Request):
"""Возвращает историю чата пользователя"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT message, is_user, created_at FROM chat_history
WHERE user_id = $1
ORDER BY id ASC
""", telegram_id)
history = []
for row in rows:
history.append({
"message": row["message"],
"is_user": row["is_user"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
})
return {"history": history}
@router.post("/history/clear")
async def clear_chat_history(request: Request):
"""Очищает историю чата пользователя"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM chat_history WHERE user_id = $1", telegram_id)
return {"status": "ok"}
+274
View File
@@ -0,0 +1,274 @@
import random
import time
import hashlib # 🔧 НОВОЕ: импорт hashlib для стабильного хэширования
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
router = APIRouter(prefix="/bubblewords", tags=["bubblewords"])
def split_word_into_parts(word: str, forbidden_parts: set = None) -> list:
"""Разбивает польское слово на части по 1-3 буквы.
forbidden_parts — части, которые уже заняты другими словами на уровне.
Если не удалось найти уникальное разбиение — добавляет суффикс с индексом слова."""
if forbidden_parts is None:
forbidden_parts = set()
n = len(word)
attempts = 0
max_attempts = 50 # Увеличиваем попытки
while attempts < max_attempts:
parts = []
i = 0
while i < n:
remaining = n - i
if remaining == 1:
parts.append(word[i:])
i += 1
elif remaining == 2:
if random.random() < 0.4:
parts.append(word[i:i+1])
parts.append(word[i+1:])
else:
parts.append(word[i:])
i += 2
elif remaining == 3:
choice = random.choice([1, 2, 3])
if choice == 1:
parts.append(word[i:i+1])
parts.append(word[i+1:])
elif choice == 2:
parts.append(word[i:i+2])
parts.append(word[i+2:])
else:
parts.append(word[i:])
i += 3
elif remaining == 4:
choice = random.choice([(2, 2), (1, 3), (3, 1), (1, 1, 2), (2, 1, 1)])
for size in choice:
parts.append(word[i:i+size])
i += size
elif remaining == 5:
choice = random.choice([(2, 3), (3, 2), (2, 2, 1), (2, 1, 2)])
for size in choice:
parts.append(word[i:i+size])
i += size
elif remaining == 6:
choice = random.choice([(3, 3), (2, 2, 2), (3, 2, 1), (2, 3, 1)])
for size in choice:
parts.append(word[i:i+size])
i += size
else:
size = random.choice([2, 3])
if i + size > n:
size = n - i
parts.append(word[i:i+size])
i += size
# Проверяем конфликт
conflict = False
for part in parts:
if part in forbidden_parts:
conflict = True
break
if not conflict:
return parts
attempts += 1
# Если за 50 попыток не нашли уникальное разбиение —
# разбиваем слово ПО БУКВАМ и добавляем суффикс с хешем слова
# Такой суффикс гарантированно уникален для каждого слова
# 🔧 ИСПРАВЛЕНО: заменён hash() на hashlib.md5 для стабильности между запусками
word_hash = hashlib.md5(word.encode('utf-8')).hexdigest()[:4] # Берём первые 4 символа MD5
unique_parts = []
for idx, letter in enumerate(word):
unique_parts.append(f"{letter}.{word_hash}.{idx}")
return unique_parts
@router.get("/next")
async def get_next_bubble_level(request: Request):
"""Выдаёт слова и их части для уровня пузырей с фильтрацией по уровню."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT app_language, current_level FROM users WHERE telegram_id = $1", telegram_id
)
if not user:
raise HTTPException(401, "User not registered")
lang = user["app_language"] or "pl"
current_level = user["current_level"] or "A1"
progress = await conn.fetchrow(
"SELECT levels_completed FROM bubble_words_progress WHERE user_id = $1",
telegram_id
)
current_bubble_level = (progress["levels_completed"] if progress else 0) + 1
# Прогрессия по количеству слов на уровне
if current_bubble_level <= 10:
num_words = random.choice([3, 4])
elif current_bubble_level <= 25:
num_words = random.choice([5, 6])
else:
num_words = random.choice([7, 8])
# Запрос слов с фильтром по уровню и level_id IS NULL
words = await conn.fetch("""
SELECT word_pl, translation_ru, translation_uk, translation_en
FROM (
SELECT DISTINCT ON (word_pl) word_pl, translation_ru, translation_uk, translation_en
FROM level_words
WHERE level = $1 AND level_id IS NULL
) AS unique_words
ORDER BY RANDOM()
LIMIT $2
""", current_level, num_words)
if len(words) < 2:
raise HTTPException(404, "Not enough words")
translation_field = f"translation_{lang}"
result_words = []
all_parts = []
forbidden_parts = set()
for w in words:
word_pl = w["word_pl"]
parts = split_word_into_parts(word_pl, forbidden_parts)
translation = w.get(translation_field) or w.get("translation_en") or word_pl
result_words.append({
"word_pl": word_pl,
"translation": translation,
"parts": parts,
})
for part in parts:
forbidden_parts.add(part)
all_parts.append({
"text": part,
"word_pl": word_pl,
})
random.shuffle(all_parts)
return {
"words": result_words,
"bubbles": all_parts,
"level": current_bubble_level,
}
@router.post("/complete")
async def complete_bubble_level(request: Request):
"""Завершение уровня пузырей, начисление очков и проверка достижений"""
telegram_id, _ = await require_auth(request)
body = await request.json()
hints_used = body.get("hints_used", 0)
words_count = body.get("words_count", 3)
completion_time = body.get("completion_time")
hints_used = min(max(0, hints_used), 3)
score = max(10, 100 - hints_used * 10)
pool = await get_pool()
async with pool.acquire() as conn:
user_exists = await conn.fetchval(
"SELECT 1 FROM users WHERE telegram_id = $1", telegram_id
)
if not user_exists:
raise HTTPException(401, "User not registered")
# Получаем current_level пользователя
current_level = await conn.fetchval(
"SELECT current_level FROM users WHERE telegram_id = $1", telegram_id
) or "A1"
await conn.execute("""
INSERT INTO bubble_words_progress (user_id, levels_completed, total_score, level)
VALUES ($1, 1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET levels_completed = bubble_words_progress.levels_completed + 1,
total_score = bubble_words_progress.total_score + $2,
level = $3
""", telegram_id, score, current_level)
# Проверка достижений
from routers.achievements import check_and_award_achievements
new_achievements = []
total_levels = await conn.fetchval(
"SELECT levels_completed FROM bubble_words_progress WHERE user_id = $1",
telegram_id
) or 0
ach = await check_and_award_achievements(telegram_id, "bubble_levels", total_levels)
new_achievements.extend(ach)
if hints_used == 0:
ach = await check_and_award_achievements(telegram_id, "bubble_no_hints", 1)
new_achievements.extend(ach)
if score == 100:
ach = await check_and_award_achievements(telegram_id, "bubble_perfect", 1)
new_achievements.extend(ach)
if completion_time is not None and completion_time > 0 and int(completion_time) <= 45:
ach = await check_and_award_achievements(telegram_id, "bubble_speed", 45)
new_achievements.extend(ach)
wordsearch_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM user_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
sentence_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
wordquiz_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
bubblewords_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM bubble_words_progress WHERE user_id = $1",
telegram_id
) or 0
grand_total = wordsearch_score + sentence_score + wordquiz_score + bubblewords_score
ach = await check_and_award_achievements(telegram_id, "total_score", grand_total)
new_achievements.extend(ach)
return {
"status": "ok",
"score": score,
"completion_time": completion_time,
"new_achievements": new_achievements,
}
@router.get("/progress")
async def get_bubble_progress(request: Request):
"""Статистика пользователя в Bubble Words"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
progress = await conn.fetchrow(
"SELECT levels_completed, total_score FROM bubble_words_progress WHERE user_id = $1",
telegram_id
)
return {
"levels_completed": progress["levels_completed"] if progress else 0,
"total_score": progress["total_score"] if progress else 0,
}
+725
View File
@@ -0,0 +1,725 @@
"""
Feed Router — посты для карусели (новости / партнёры / контакты / приложения).
Публичные эндпоинты:
- GET /feed/{section}?lang={lang} — лента постов с локализацией
- GET /feed/post/{id}?lang={lang} — один пост
Админские эндпоинты:
- POST /admin/feed/post — создать пост + автоперевод на 4 языка
- PUT /admin/feed/post/{id} — редактировать (опционально перевести заново)
- POST /admin/feed/post/{id}/delete — удалить (вложения удалит CASCADE)
- POST /admin/feed/upload-image — загрузить картинку (multipart, до 3 на пост)
- POST /admin/feed/translate — перевести title+body вручную (без сохранения)
Логика локализации:
- В БД хранятся 4 версии поста (title_pl/ru/uk/en, body_pl/ru/uk/en).
- Юзер получает только свою версию.
- Fallback: _{lang} → _en → _pl.
"""
import logging
import json
import re
import uuid
import os
from pathlib import Path
from typing import Optional, Any
import httpx
from fastapi import APIRouter, Request, HTTPException, UploadFile, File, Form
from auth import require_auth
from database import get_pool
from config import (
TOGETHER_API_KEY,
ADMIN_IDS,
FEED_SECTIONS,
FEED_TARGET_LANGUAGES,
MAX_FEED_ATTACHMENTS,
MAX_FEED_IMAGE_SIZE_BYTES,
FEED_ALLOWED_IMAGE_TYPES,
UPLOADS_DIR,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["feed"])
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
TRANSLATE_MODEL = "MiniMaxAI/MiniMax-M3"
# 🔧 ФИКС: бэк отдаёт полный URL картинки, чтобы фронт не гадал,
# куда склеивать относительный путь /uploads/feed/...
API_PUBLIC_URL = "https://api.filwordspl.com"
# ============ HELPERS ============
def _require_admin(telegram_id: int) -> None:
"""Проверка, что пользователь — админ. Бросает 403 если нет."""
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
def _localized_field(row: dict, field: str, lang: str) -> str:
"""
Достаёт локализованное поле из строки БД.
Fallback: _{lang} → _en → _pl → ''.
"""
if not row:
return ""
return (
row.get(f"{field}_{lang}")
or row.get(f"{field}_en")
or row.get(f"{field}_pl")
or ""
)
def _serialize_post(row: dict, attachments: list, lang: str) -> dict:
"""
Собирает финальный объект поста для фронта:
локализованный title/body, ссылки, метаданные, картинки.
"""
return {
"id": row["id"],
"section": row["section"],
"title": _localized_field(row, "title", lang),
"body": _localized_field(row, "body", lang),
"images": [
f"{API_PUBLIC_URL}{a['file_path']}" if a["file_path"].startswith("/") else a["file_path"]
for a in attachments
],
"linkUrl": row.get("link_url"),
"telegramUrl": row.get("telegram_url"),
"createdAt": row["created_at"].isoformat() if row.get("created_at") else None,
"updatedAt": row["updated_at"].isoformat() if row.get("updated_at") else None,
"expiresAt": row["expires_at"].isoformat() if row.get("expires_at") else None,
"isPinned": bool(row.get("is_pinned")),
}
# ============ ПУБЛИЧНЫЕ ЭНДПОИНТЫ ============
@router.get("/feed/{section}")
async def get_feed_section(request: Request, section: str, lang: Optional[str] = None):
"""
Возвращает ленту постов для секции (news / partners / contacts / apps).
- Закреплённые посты — сверху.
- Остальные — по created_at DESC.
- Истёкшие (expires_at < NOW()) — НЕ отдаём.
- Опубликованные (is_published = TRUE) — только они.
"""
telegram_id, _ = await require_auth(request)
if section not in FEED_SECTIONS:
raise HTTPException(400, f"Invalid section: {section}")
pool = await get_pool()
async with pool.acquire() as conn:
# Если lang не передан — берём из профиля юзера
if not lang or lang not in FEED_TARGET_LANGUAGES:
lang = await conn.fetchval(
"SELECT app_language FROM users WHERE telegram_id = $1",
telegram_id,
) or "pl"
if lang not in FEED_TARGET_LANGUAGES:
lang = "pl"
# Тянем посты: закреплённые сверху, потом по дате
rows = await conn.fetch("""
SELECT *
FROM feed_posts
WHERE section = $1
AND is_published = TRUE
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY is_pinned DESC, created_at DESC
""", section)
if not rows:
return {"posts": [], "section": section, "lang": lang}
# Одним запросом тянем все вложения для всех постов (без N+1)
post_ids = [r["id"] for r in rows]
att_rows = await conn.fetch("""
SELECT post_id, file_path, order_number
FROM feed_attachments
WHERE post_id = ANY($1::bigint[])
ORDER BY post_id, order_number ASC
""", post_ids)
# Группируем вложения по post_id
att_by_post: dict[int, list] = {}
for a in att_rows:
att_by_post.setdefault(a["post_id"], []).append(dict(a))
posts = [
_serialize_post(dict(r), att_by_post.get(r["id"], []), lang)
for r in rows
]
return {"posts": posts, "section": section, "lang": lang}
@router.get("/feed/post/{post_id}")
async def get_feed_post(request: Request, post_id: int, lang: Optional[str] = None):
"""Возвращает один пост по ID (с локализацией)."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
if not lang or lang not in FEED_TARGET_LANGUAGES:
lang = await conn.fetchval(
"SELECT app_language FROM users WHERE telegram_id = $1",
telegram_id,
) or "pl"
if lang not in FEED_TARGET_LANGUAGES:
lang = "pl"
row = await conn.fetchrow("""
SELECT * FROM feed_posts WHERE id = $1 AND is_published = TRUE
""", post_id)
if not row:
raise HTTPException(404, "Post not found")
att_rows = await conn.fetch("""
SELECT file_path, order_number
FROM feed_attachments
WHERE post_id = $1
ORDER BY order_number ASC
""", post_id)
return _serialize_post(dict(row), [dict(a) for a in att_rows], lang)
# ============ AI-ПЕРЕВОД ============
async def translate_to_all_languages(
title: str,
body: str,
source_lang: str,
) -> dict:
"""
Переводит title и body с source_lang на все 4 языка (PL/RU/UK/EN).
Возвращает dict с 8 полями:
title_pl, title_ru, title_uk, title_en,
body_pl, body_ru, body_uk, body_en.
Если AI упал или вернул невалидный JSON — возвращает пустой dict.
Caller должен решить, использовать ли fallback (source_lang для всех языков).
"""
if not TOGETHER_API_KEY:
logger.error("TOGETHER_API_KEY not configured — cannot translate")
return {}
if source_lang not in FEED_TARGET_LANGUAGES:
logger.error(f"Invalid source_lang: {source_lang}")
return {}
system_prompt = f"""Ты — переводчик. Переведи title и body на 4 языка: PL, RU, UK, EN.
Язык оригинала: {source_lang}.
Верни СТРОГО JSON без markdown и без пояснений:
{{
"title_pl": "...", "title_ru": "...", "title_uk": "...", "title_en": "...",
"body_pl": "...", "body_ru": "...", "body_uk": "...", "body_en": "..."
}}
ВАЖНО:
- Если source_lang = ru, то title_ru и body_ru оставь БЕЗ изменений.
- Если source_lang = pl, то title_pl и body_pl оставь БЕЗ изменений.
- Если source_lang = uk, то title_uk и body_uk оставь БЕЗ изменений.
- Если source_lang = en, то title_en и body_en оставь БЕЗ изменений.
- Сохраняй переносы строк внутри body (используй \\n в JSON).
- Не добавляй никаких полей кроме указанных 8.
- Если title пустой — оставь все title_* пустыми строками."""
user_prompt = f"title: {title}\nbody: {body}"
try:
async with httpx.AsyncClient(timeout=90.0) as client:
response = await client.post(
TOGETHER_API_URL,
headers={
"Authorization": f"Bearer {TOGETHER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": TRANSLATE_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"max_tokens": 2000,
"temperature": 0.3,
},
)
if response.status_code != 200:
logger.error(
f"Translate API error: {response.status_code}, body={response.text[:200]}"
)
return {}
data = response.json()
raw = data["choices"][0]["message"]["content"].strip()
# Если AI обернул в ```json ... ``` — снимаем обёртку
if raw.startswith("```"):
raw = re.sub(r'^```(?:json)?\s*', '', raw)
raw = re.sub(r'\s*```$', '', raw)
# Иногда AI добавляет текст до/после JSON — вырезаем первый {...}
json_match = re.search(r'\{.*\}', raw, re.DOTALL)
if not json_match:
logger.error(f"AI response has no JSON: {raw[:200]}")
return {}
parsed = json.loads(json_match.group(0))
# Валидация: все 8 полей должны быть строками
expected_keys = [
"title_pl", "title_ru", "title_uk", "title_en",
"body_pl", "body_ru", "body_uk", "body_en",
]
result = {}
for key in expected_keys:
value = parsed.get(key, "")
result[key] = str(value) if value is not None else ""
# Гарантия: source_lang остаётся без изменений
result[f"title_{source_lang}"] = title
result[f"body_{source_lang}"] = body
return result
except json.JSONDecodeError as e:
logger.error(f"Failed to parse AI JSON: {e}")
return {}
except Exception as e:
logger.error(f"Translate error: {str(e)}")
return {}
# ============ АДМИНСКИЕ ЭНДПОИНТЫ ============
@router.post("/admin/feed/post")
async def admin_create_feed_post(request: Request):
"""
Создать feed-пост.
Тело запроса (JSON):
{
"section": "news" | "partners" | "contacts" | "apps",
"source_language": "ru" | "pl" | "uk" | "en",
"title": "Заголовок на source_language",
"body": "Текст поста на source_language",
"link_url": "https://..." (опционально),
"telegram_url": "https://t.me/..." (опционально),
"is_pinned": false (опционально),
"expires_at": "2026-10-15T23:59:59Z" (опционально, для partners)
}
Backend автоматически переведёт title/body на 4 языка через MiniMax-M3.
Если перевод упал — сохраняем source_language для всех языков (fallback).
"""
telegram_id, _ = await require_auth(request)
_require_admin(telegram_id)
body = await request.json()
section = body.get("section", "").strip()
source_language = body.get("source_language", "ru").strip().lower()
title = (body.get("title") or "").strip()
post_body = (body.get("body") or "").strip()
link_url = (body.get("link_url") or "").strip() or None
telegram_url = (body.get("telegram_url") or "").strip() or None
is_pinned = bool(body.get("is_pinned", False))
expires_at_raw = body.get("expires_at")
# Валидация
if section not in FEED_SECTIONS:
raise HTTPException(400, f"Invalid section. Allowed: {sorted(FEED_SECTIONS)}")
if source_language not in FEED_TARGET_LANGUAGES:
raise HTTPException(400, f"Invalid source_language. Allowed: {FEED_TARGET_LANGUAGES}")
if not title and not post_body:
raise HTTPException(400, "Either title or body is required")
if len(title) > 200:
raise HTTPException(400, "Title too long (max 200 characters)")
if len(post_body) > 5000:
raise HTTPException(400, "Body too long (max 5000 characters)")
# Парсим expires_at если передан
expires_at = None
if expires_at_raw:
try:
from datetime import datetime, timezone
# Поддерживаем ISO 8601 с 'Z' или без
normalized = expires_at_raw.replace("Z", "+00:00")
expires_at = datetime.fromisoformat(normalized)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
except (ValueError, AttributeError):
raise HTTPException(400, "Invalid expires_at format (use ISO 8601)")
# Переводим на 4 языка
logger.info(f"Translating feed post from {source_language} → PL/RU/UK/EN")
translations = await translate_to_all_languages(title, post_body, source_language)
# Fallback: если перевод не удался — копируем source для всех языков
if not translations:
logger.warning("Translation failed — falling back to source_language for all")
translations = {}
for lang in FEED_TARGET_LANGUAGES:
translations[f"title_{lang}"] = title
translations[f"body_{lang}"] = post_body
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO feed_posts (
section, source_language,
title_pl, title_ru, title_uk, title_en,
body_pl, body_ru, body_uk, body_en,
link_url, telegram_url,
author_id, is_pinned, is_published, expires_at
) VALUES (
$1, $2,
$3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12,
$13, $14, TRUE, $15
)
RETURNING id
""",
section, source_language,
translations["title_pl"], translations["title_ru"],
translations["title_uk"], translations["title_en"],
translations["body_pl"], translations["body_ru"],
translations["body_uk"], translations["body_en"],
link_url, telegram_url,
telegram_id, is_pinned, expires_at,
)
return {"status": "ok", "post_id": row["id"]}
@router.put("/admin/feed/post/{post_id}")
async def admin_update_feed_post(request: Request, post_id: int):
"""
Редактировать feed-пост.
Тело запроса (JSON) — все поля опциональны:
{
"title": "...", (на source_language)
"body": "...", (на source_language)
"link_url": "..." | null,
"telegram_url": "..." | null,
"is_pinned": true/false,
"is_published": true/false,
"expires_at": "..." | null,
"translate_again": true — перевести заново через AI
}
"""
telegram_id, _ = await require_auth(request)
_require_admin(telegram_id)
body = await request.json()
pool = await get_pool()
async with pool.acquire() as conn:
existing = await conn.fetchrow("""
SELECT * FROM feed_posts WHERE id = $1
""", post_id)
if not existing:
raise HTTPException(404, "Post not found")
source_language = existing["source_language"]
# Мерджим поля: если не передано — оставляем старое
title = body.get("title", existing.get(f"title_{source_language}") or "")
post_body = body.get("body", existing.get(f"body_{source_language}") or "")
link_url = body.get("link_url", existing["link_url"])
telegram_url = body.get("telegram_url", existing["telegram_url"])
is_pinned = body.get("is_pinned", existing["is_pinned"])
is_published = body.get("is_published", existing["is_published"])
# expires_at: явное None → очистить, отсутствие ключа → оставить
if "expires_at" in body:
expires_at_raw = body.get("expires_at")
if expires_at_raw is None:
expires_at = None
else:
try:
from datetime import datetime, timezone
normalized = str(expires_at_raw).replace("Z", "+00:00")
expires_at = datetime.fromisoformat(normalized)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
except (ValueError, AttributeError):
raise HTTPException(400, "Invalid expires_at format (use ISO 8601)")
else:
expires_at = existing["expires_at"]
# Если translate_again = True — переводим заново
if body.get("translate_again"):
logger.info(f"Re-translating feed post #{post_id} from {source_language}")
translations = await translate_to_all_languages(title, post_body, source_language)
if not translations:
logger.warning("Translation failed — keeping old translations for other languages")
# Оставляем старые версии для не-source языков, обновляем только source
translations = {
"title_pl": existing["title_pl"], "title_ru": existing["title_ru"],
"title_uk": existing["title_uk"], "title_en": existing["title_en"],
"body_pl": existing["body_pl"], "body_ru": existing["body_ru"],
"body_uk": existing["body_uk"], "body_en": existing["body_en"],
}
translations[f"title_{source_language}"] = title
translations[f"body_{source_language}"] = post_body
else:
# Без translate_again — обновляем ТОЛЬКО source_language, остальные не трогаем
translations = {
"title_pl": existing["title_pl"], "title_ru": existing["title_ru"],
"title_uk": existing["title_uk"], "title_en": existing["title_en"],
"body_pl": existing["body_pl"], "body_ru": existing["body_ru"],
"body_uk": existing["body_uk"], "body_en": existing["body_en"],
}
translations[f"title_{source_language}"] = title
translations[f"body_{source_language}"] = post_body
await conn.execute("""
UPDATE feed_posts SET
title_pl = $1, title_ru = $2, title_uk = $3, title_en = $4,
body_pl = $5, body_ru = $6, body_uk = $7, body_en = $8,
link_url = $9, telegram_url = $10,
is_pinned = $11, is_published = $12,
expires_at = $13,
updated_at = NOW(),
edited_at = NOW()
WHERE id = $14
""",
translations["title_pl"], translations["title_ru"],
translations["title_uk"], translations["title_en"],
translations["body_pl"], translations["body_ru"],
translations["body_uk"], translations["body_en"],
link_url, telegram_url,
is_pinned, is_published,
expires_at,
post_id,
)
return {"status": "ok", "post_id": post_id}
# 🔧 ФИКС (18.09.2026): POST вместо DELETE.
# Причина: Telegram WebView (iOS / macOS) не пропускает DELETE из-за CORS preflight,
# который Cloudflare кеширует на 20 дней. POST без Content-Type — simple request,
# preflight не отправляется, Cloudflare не вмешивается.
#
# 🔧 ФИКС (19.09.2026): удаляем не только запись из БД, но и файлы с диска.
# Раньше файлы копились мёртвым грузом (27 мёртвых файлов за день тестов).
@router.post("/admin/feed/post/{post_id}/delete")
async def admin_delete_feed_post(request: Request, post_id: int):
"""
Удалить feed-пост (POST вместо DELETE — обход CORS preflight в WebView).
Что делаем:
1. Читаем пути всех файлов поста из feed_attachments ДО удаления из БД.
2. Удаляем пост из БД (ON DELETE CASCADE уберёт вложения).
3. Удаляем файлы с диска.
Если файл уже удалён вручную / нет прав — не роняем запрос, только логируем.
Пост из БД уже удалён — файл вторичен.
"""
telegram_id, _ = await require_auth(request)
_require_admin(telegram_id)
pool = await get_pool()
async with pool.acquire() as conn:
# 1. Читаем пути файлов ДО удаления
attachment_rows = await conn.fetch("""
SELECT file_path FROM feed_attachments WHERE post_id = $1
""", post_id)
# 2. Удаляем пост (CASCADE уберёт feed_attachments)
result = await conn.execute("""
DELETE FROM feed_posts WHERE id = $1
""", post_id)
affected = int(result.split()[-1])
if affected == 0:
raise HTTPException(404, "Post not found")
logger.info(f"Feed post #{post_id} deleted by admin {telegram_id}")
# 3. Удаляем файлы с диска (вне pool.acquire — чтобы не держать коннект)
# file_path в БД = "/uploads/feed/16_0_abc.jpg"
# на диске = "/root/filwordspl/backend/uploads/feed/16_0_abc.jpg"
# UPLOADS_DIR = "/root/filwordspl/backend/uploads" (Path)
removed_count = 0
failed_count = 0
for row in attachment_rows:
file_path = row["file_path"] # "/uploads/feed/16_0_abc.jpg"
# Убираем префикс "/uploads/" → "feed/16_0_abc.jpg"
rel_path = file_path.replace("/uploads/", "", 1).lstrip("/")
abs_path = Path(UPLOADS_DIR) / rel_path
try:
if abs_path.exists():
abs_path.unlink()
removed_count += 1
logger.info(f"[delete_feed_post] removed file: {abs_path}")
else:
logger.warning(f"[delete_feed_post] file not found: {abs_path}")
except Exception as e:
failed_count += 1
# Не роняем запрос — пост уже удалён из БД, файл вторичен
logger.error(f"[delete_feed_post] failed to remove {abs_path}: {e}")
if removed_count or failed_count:
logger.info(
f"[delete_feed_post] post #{post_id}: "
f"files removed={removed_count}, failed={failed_count}"
)
return {"status": "ok", "files_removed": removed_count}
@router.post("/admin/feed/upload-image")
async def admin_upload_feed_image(
request: Request,
post_id: int = Form(...),
file: UploadFile = File(...),
):
"""
Загрузить картинку к посту.
- Максимум MAX_FEED_ATTACHMENTS (3) на пост.
- Максимум MAX_FEED_IMAGE_SIZE_MB (5 МБ) на файл.
- Только image/jpeg, image/png, image/webp, image/gif.
Возвращает путь к файлу (используется как URL на фронте).
"""
telegram_id, _ = await require_auth(request)
_require_admin(telegram_id)
# Валидация MIME-типа
content_type = (file.content_type or "").lower()
if content_type not in FEED_ALLOWED_IMAGE_TYPES:
raise HTTPException(
400,
f"Invalid image type: {content_type}. Allowed: {sorted(FEED_ALLOWED_IMAGE_TYPES)}",
)
# Читаем файл, проверяем размер
content = await file.read()
if len(content) == 0:
raise HTTPException(400, "Empty file")
if len(content) > MAX_FEED_IMAGE_SIZE_BYTES:
raise HTTPException(
400,
f"File too large: {len(content)} bytes. Max: {MAX_FEED_IMAGE_SIZE_BYTES} bytes",
)
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем, что пост существует
post_exists = await conn.fetchval(
"SELECT 1 FROM feed_posts WHERE id = $1", post_id
)
if not post_exists:
raise HTTPException(404, "Post not found")
# Проверяем лимит вложений
current_count = await conn.fetchval("""
SELECT COUNT(*) FROM feed_attachments WHERE post_id = $1
""", post_id) or 0
if current_count >= MAX_FEED_ATTACHMENTS:
raise HTTPException(
400,
f"Max {MAX_FEED_ATTACHMENTS} attachments per post (already {current_count})",
)
# Готовим имя файла: {post_id}_{order}_{uuid8}.{ext}
ext_map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
}
ext = ext_map.get(content_type, "jpg")
order_number = current_count # 0, 1, 2
filename = f"{post_id}_{order_number}_{uuid.uuid4().hex[:8]}.{ext}"
# Сохраняем на диск
feed_uploads_dir = Path(UPLOADS_DIR) / "feed"
feed_uploads_dir.mkdir(parents=True, exist_ok=True)
file_path_disk = feed_uploads_dir / filename
file_path_disk.write_bytes(content)
# Публичный URL для фронта (относительный — фронт сам подставит API_BASE)
public_path = f"/uploads/feed/{filename}"
# Пишем в БД
await conn.execute("""
INSERT INTO feed_attachments (post_id, file_path, file_type, order_number)
VALUES ($1, $2, $3, $4)
""", post_id, public_path, "image", order_number)
return {
"status": "ok",
"file_path": public_path,
"order_number": order_number,
}
@router.post("/admin/feed/translate")
async def admin_translate_feed(request: Request):
"""
Перевести title+body БЕЗ сохранения в БД.
Используется для превью перевода в админ-UI.
Тело запроса:
{
"title": "...",
"body": "...",
"source_language": "ru"
}
Возвращает:
{
"title_pl": "...", "title_ru": "...", "title_uk": "...", "title_en": "...",
"body_pl": "...", "body_ru": "...", "body_uk": "...", "body_en": "..."
}
"""
telegram_id, _ = await require_auth(request)
_require_admin(telegram_id)
body = await request.json()
title = (body.get("title") or "").strip()
post_body = (body.get("body") or "").strip()
source_language = body.get("source_language", "ru").strip().lower()
if source_language not in FEED_TARGET_LANGUAGES:
raise HTTPException(400, f"Invalid source_language. Allowed: {FEED_TARGET_LANGUAGES}")
if not title and not post_body:
raise HTTPException(400, "Either title or body is required")
translations = await translate_to_all_languages(title, post_body, source_language)
if not translations:
raise HTTPException(500, "Translation failed (AI unavailable or invalid response)")
return translations
+330
View File
@@ -0,0 +1,330 @@
import json
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
router = APIRouter(prefix="/game", tags=["game"])
@router.get("/progress")
async def get_progress(request: Request):
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT last_completed_level, current_level FROM users WHERE telegram_id = $1",
telegram_id
)
if not user:
return {
"last_completed_level": 0,
"total_score": 0,
"current_level": "A1"
}
current_level = user["current_level"] or "A1"
# Очки из филвордов
wordsearch_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM user_progress WHERE user_id = $1",
telegram_id
)
# Очки из предложений
sentence_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1",
telegram_id
)
# Очки из квиза
wordquiz_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM word_quiz_progress WHERE user_id = $1",
telegram_id
)
# Очки из Bubble Words
bubblewords_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM bubble_words_progress WHERE user_id = $1",
telegram_id
)
total_score = (wordsearch_score or 0) + (sentence_score or 0) + (wordquiz_score or 0) + (bubblewords_score or 0)
return {
"last_completed_level": user["last_completed_level"] or 0,
"total_score": total_score,
"current_level": current_level
}
@router.get("/level/{level_number}")
async def get_level(request: Request, level_number: int):
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT app_language, current_level, last_completed_level FROM users WHERE telegram_id = $1", telegram_id
)
if not user:
raise HTTPException(401, "User not registered")
lang = user["app_language"] or "pl"
current_level = user["current_level"] or "A1"
last_completed = user["last_completed_level"] or 0
# Проверяем, что уровень доступен (не дальше следующего за пройденным)
if level_number > 1 and level_number > last_completed + 1:
raise HTTPException(403, "Level not available yet. Complete previous level first.")
# Получаем уровень по его номеру ВНУТРИ выбранного уровня
level = await conn.fetchrow(
"SELECT * FROM levels WHERE level = $1 AND level_number = $2", current_level, level_number
)
if not level:
raise HTTPException(404, "Level not found")
words = await conn.fetch(
"SELECT * FROM level_words WHERE level_id = $1", level["id"]
)
progress = await conn.fetchrow(
"SELECT * FROM user_progress WHERE user_id = $1 AND level_id = $2",
telegram_id, level["id"],
)
word_list = []
for w in words:
translation = w.get(f"translation_{lang}", "") or w.get("translation_pl", "") or w.get("translation_en", "")
word_list.append({
"word_pl": w["word_pl"],
"translation": translation,
})
grid_letters = level["grid_letters"]
if isinstance(grid_letters, str):
grid_letters = json.loads(grid_letters)
word_paths = level["word_paths"]
if isinstance(word_paths, str):
word_paths = json.loads(word_paths)
return {
"level": {
"id": level["id"],
"level_number": level["level_number"],
"grid_size": level["grid_size"],
"grid_letters": grid_letters,
"word_paths": word_paths,
"level": level["level"],
},
"words": word_list,
"progress": {
"completed": progress is not None,
"hints_used": progress["hints_used"] if progress else 0,
"score": progress["score"] if progress else 0,
},
}
@router.post("/check-word")
async def check_word(request: Request):
telegram_id, _ = await require_auth(request)
body = await request.json()
level_id = body.get("level_id")
selected_cells = body.get("cells", [])
if not level_id or not selected_cells:
raise HTTPException(400, "level_id and cells required")
pool = await get_pool()
found_word = None
perfect_count = body.get("perfect_streak", 0)
async with pool.acquire() as conn:
level = await conn.fetchrow("SELECT * FROM levels WHERE id = $1", level_id)
if not level:
raise HTTPException(404, "Level not found")
word_paths = level["word_paths"]
if isinstance(word_paths, str):
word_paths = json.loads(word_paths)
grid_letters = level["grid_letters"]
if isinstance(grid_letters, str):
grid_letters = json.loads(grid_letters)
selected_word = ''
for cell in selected_cells:
r, c = cell[0], cell[1]
try:
selected_word += grid_letters[r][c]
except (IndexError, TypeError):
continue
selected_word = selected_word.lower()
for word in word_paths.keys():
if word.lower() == selected_word:
found_word = word
break
# 🔧 ИСПРАВЛЕНО: вызов достижений ВНЕ блока async with pool.acquire()
if found_word:
from routers.achievements import check_and_award_achievements
new_achievements = []
if perfect_count > 0:
ach = await check_and_award_achievements(telegram_id, "perfect_words", perfect_count)
new_achievements.extend(ach)
return {
"status": "ok",
"found": True,
"word": found_word,
"new_achievements": new_achievements
}
return {"status": "ok", "found": False}
@router.post("/complete-level")
async def complete_level(request: Request):
"""Завершение уровня с проверкой на читерство и выдачей достижений"""
telegram_id, _ = await require_auth(request)
body = await request.json()
level_id = body.get("level_id")
hints_used = body.get("hints_used", 0)
completion_time = body.get("completion_time")
perfect_streak = body.get("perfect_streak", 0)
if not level_id:
raise HTTPException(400, "level_id required")
hints_used = min(max(0, hints_used), 3)
# Защита от читерства: минимальное время 5 секунд
if completion_time is not None and completion_time < 5:
raise HTTPException(400, "Completion time too fast. Minimum 5 seconds required.")
pool = await get_pool()
# 🔧 ИСПРАВЛЕНО: все проверки и запись прогресса — внутри одного блока
# все данные для достижений собираем здесь, а сами вызовы делаем снаружи
already_completed = False
existing_score = 0
score = 0
levels_completed = 0
grand_total = 0
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT id, telegram_id, last_completed_level, current_level FROM users WHERE telegram_id = $1",
telegram_id
)
if not user:
raise HTTPException(401, "User not registered")
level = await conn.fetchrow(
"SELECT id, level_number, grid_size, level FROM levels WHERE id = $1", level_id
)
if not level:
raise HTTPException(404, "Level not found")
current_level_number = level["level_number"]
level_name = level["level"] or "A1"
# Проверяем, что уровень принадлежит текущему уровню пользователя
if level_name != (user["current_level"] or "A1"):
raise HTTPException(403, "This level does not belong to your current level")
existing_progress = await conn.fetchrow(
"SELECT score FROM user_progress WHERE user_id = $1 AND level_id = $2",
telegram_id, level_id
)
if existing_progress:
already_completed = True
existing_score = existing_progress["score"]
else:
score = max(10, 100 - hints_used * 10)
await conn.execute(
"""INSERT INTO user_progress (user_id, level_id, completed_at, hints_used, score, level)
VALUES ($1, $2, NOW(), $3, $4, $5)""",
telegram_id, level_id, hints_used, score, level_name,
)
# Обновляем last_completed_level (максимальный номер пройденного уровня)
await conn.execute(
"""UPDATE users
SET last_completed_level = GREATEST(last_completed_level, $2)
WHERE telegram_id = $1""",
telegram_id, current_level_number,
)
# Собираем данные для достижений внутри блока
levels_completed = await conn.fetchval(
"SELECT COUNT(*) FROM user_progress WHERE user_id = $1",
telegram_id
) or 0
total_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM user_progress WHERE user_id = $1",
telegram_id
) or 0
sentence_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1",
telegram_id
) or 0
wordquiz_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
bubblewords_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM bubble_words_progress WHERE user_id = $1",
telegram_id
) or 0
grand_total = total_score + sentence_score + wordquiz_score + bubblewords_score
# Если уровень уже был пройден — просто возвращаем результат
if already_completed:
return {
"status": "ok",
"score": existing_score,
"already_completed": True,
"new_achievements": []
}
# 🔧 ИСПРАВЛЕНО: вызовы достижений ВНЕ блока async with pool.acquire()
from routers.achievements import check_and_award_achievements
new_achievements = []
ach = await check_and_award_achievements(telegram_id, "levels_completed", levels_completed)
new_achievements.extend(ach)
if hints_used == 0:
ach = await check_and_award_achievements(telegram_id, "no_hints_level", 1)
new_achievements.extend(ach)
if completion_time is not None and completion_time > 0:
if int(completion_time) <= 60:
ach = await check_and_award_achievements(telegram_id, "speed_level", 60)
new_achievements.extend(ach)
ach = await check_and_award_achievements(telegram_id, "total_score", grand_total)
new_achievements.extend(ach)
if perfect_streak >= 5:
ach = await check_and_award_achievements(telegram_id, "perfect_words", perfect_streak)
new_achievements.extend(ach)
return {
"status": "ok",
"score": score,
"already_completed": False,
"new_achievements": new_achievements
}
+152
View File
@@ -0,0 +1,152 @@
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
router = APIRouter(prefix="/hints", tags=["hints"])
@router.get("/first-letter/{level_id}")
async def get_first_letter_hint(request: Request, level_id: int):
"""Подсказка: показывает первую букву случайного ненайденного слова"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем, что уровень существует
level = await conn.fetchrow("SELECT * FROM levels WHERE id = $1", level_id)
if not level:
raise HTTPException(404, "Level not found")
# Проверяем, что пользователь существует
user_exists = await conn.fetchval(
"SELECT 1 FROM users WHERE telegram_id = $1", telegram_id
)
if not user_exists:
raise HTTPException(401, "User not registered")
# Получаем прогресс пользователя по этому уровню
progress = await conn.fetchrow(
"SELECT hints_used FROM user_progress WHERE user_id = $1 AND level_id = $2",
telegram_id, level_id
)
hints_already_used = progress["hints_used"] if progress else 0
# Получаем все слова уровня
words = await conn.fetch(
"SELECT word_pl FROM level_words WHERE level_id = $1", level_id
)
if not words:
raise HTTPException(404, "No words found for this level")
word_paths = level["word_paths"]
if isinstance(word_paths, str):
import json
word_paths = json.loads(word_paths)
# Находим первое слово, у которого есть путь
all_level_words = [w["word_pl"] for w in words]
# Ищем слово с путём (сначала основные, потом filler-слова)
found_word = None
first_cell = None
for word in all_level_words:
if word in word_paths and word_paths[word]:
found_word = word
first_cell = word_paths[word][0]
break
if not found_word:
# Если пути не нашлись — берём любое слово
found_word = all_level_words[0]
first_cell = [0, 0]
return {
"status": "ok",
"word": found_word,
"first_cell": first_cell,
"hints_used_total": hints_already_used,
}
@router.get("/word-list/{level_id}")
async def get_word_list(request: Request, level_id: int):
"""Подсказка: показывает список всех слов уровня с переводами"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем, что уровень существует
level = await conn.fetchrow("SELECT id FROM levels WHERE id = $1", level_id)
if not level:
raise HTTPException(404, "Level not found")
# Проверяем, что пользователь существует
user_exists = await conn.fetchval(
"SELECT 1 FROM users WHERE telegram_id = $1", telegram_id
)
if not user_exists:
raise HTTPException(401, "User not registered")
# Получаем прогресс пользователя
progress = await conn.fetchrow(
"SELECT hints_used FROM user_progress WHERE user_id = $1 AND level_id = $2",
telegram_id, level_id
)
hints_already_used = progress["hints_used"] if progress else 0
# Получаем слова
words = await conn.fetch(
"SELECT * FROM level_words WHERE level_id = $1 ORDER BY word_pl", level_id
)
# Определяем язык пользователя
user = await conn.fetchrow(
"SELECT app_language FROM users WHERE telegram_id = $1", telegram_id
)
lang = user["app_language"] if user else "pl"
# Формируем список с переводами
translation_field = f"translation_{lang}"
word_list = []
for w in words:
translation = (
w.get(translation_field) or
w.get("translation_pl") or
w.get("translation_en") or
""
)
word_list.append({
"word_pl": w["word_pl"],
"translation": translation,
})
return {
"status": "ok",
"words": word_list,
"total_words": len(word_list),
"hints_used_total": hints_already_used,
}
@router.get("/remaining/{level_id}")
async def get_remaining_hints(request: Request, level_id: int):
"""Возвращает количество оставшихся подсказок для уровня"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
progress = await conn.fetchrow(
"SELECT hints_used FROM user_progress WHERE user_id = $1 AND level_id = $2",
telegram_id, level_id
)
hints_used = progress["hints_used"] if progress else 0
max_hints = 3
remaining = max(0, max_hints - hints_used)
return {
"max_hints": max_hints,
"hints_used": hints_used,
"remaining": remaining,
}
+333
View File
@@ -0,0 +1,333 @@
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
from config import ADMIN_IDS
router = APIRouter(prefix="/leaderboard", tags=["leaderboard"])
LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]
@router.get("")
async def get_leaderboard(request: Request, limit: int = 50):
"""Рейтинг игроков — быстрое чтение из кэша"""
telegram_id, _ = await require_auth(request)
if limit < 1:
limit = 1
elif limit > 100:
limit = 100
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT 1, current_level FROM users WHERE telegram_id = $1", telegram_id
)
if not user:
raise HTTPException(401, "User not registered")
current_level = user["current_level"] or "A1"
# Быстрое чтение из MATERIALIZED VIEW
rows = await conn.fetch("""
SELECT * FROM leaderboard_cache
ORDER BY total_score DESC
LIMIT $1
""", limit)
leaderboard = []
for i, row in enumerate(rows):
leaderboard.append({
"rank": i + 1,
"telegram_id": row["telegram_id"],
"username": row["username"] or f"Player{row['telegram_id']}",
"total_score": row["total_score"],
"wordsearch_score": row["wordsearch_score"],
"sentence_score": row["sentence_score"],
"wordquiz_score": row["wordquiz_score"],
"words_guessed": row["words_guessed"],
"bubblewords_score": row["bubblewords_score"],
"bubble_levels_completed": row["bubble_levels_completed"],
"levels_completed": 0,
"sentences_completed": 0,
})
# Место пользователя
user_rank = await conn.fetchval("""
SELECT COUNT(*) + 1 FROM leaderboard_cache
WHERE total_score > (
SELECT COALESCE(total_score, 0) FROM leaderboard_cache WHERE telegram_id = $1
)
""", telegram_id)
# Персональная статистика пользователя
my_row = await conn.fetchrow("""
SELECT * FROM leaderboard_cache WHERE telegram_id = $1
""", telegram_id)
if my_row:
my_wordsearch = my_row["wordsearch_score"]
my_sentence = my_row["sentence_score"]
my_wordquiz = my_row["wordquiz_score"]
my_words_guessed = my_row["words_guessed"]
my_bubblewords = my_row["bubblewords_score"]
my_bubble_levels = my_row["bubble_levels_completed"]
else:
my_wordsearch = 0
my_sentence = 0
my_wordquiz = 0
my_words_guessed = 0
my_bubblewords = 0
my_bubble_levels = 0
# Считаем НАПРЯМУЮ из основных таблиц
my_wordsearch_levels = await conn.fetchval(
"SELECT COUNT(DISTINCT level_id) FROM user_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
my_sentences_completed = await conn.fetchval(
"SELECT COUNT(*) FROM sentence_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
# Если пользователя нет в кэше — считаем очки из основных таблиц
if not my_row:
my_wordsearch = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM user_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
my_sentence = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
my_wordquiz = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
my_words_guessed = await conn.fetchval(
"SELECT COALESCE(words_guessed, 0) FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
my_bubblewords = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM bubble_words_progress WHERE user_id = $1",
telegram_id
) or 0
my_bubble_levels = await conn.fetchval(
"SELECT COALESCE(levels_completed, 0) FROM bubble_words_progress WHERE user_id = $1",
telegram_id
) or 0
my_total = my_wordsearch + my_sentence + my_wordquiz + my_bubblewords
my_position = user_rank or 1
# 🔧 ФИКС N+1 (19.09.2026): 48 запросов → 5.
# Было: цикл по 6 уровням × 8 запросов = 48 round-trip к БД.
# Стало: 5 агрегированных запросов + сборка level_stats в Python.
# 1. Справочники (не зависят от юзера) — одним запросом
ref_row = await conn.fetchrow("""
SELECT
(SELECT COUNT(*) FROM levels WHERE level = 'A1') AS a1_levels,
(SELECT COUNT(*) FROM levels WHERE level = 'A2') AS a2_levels,
(SELECT COUNT(*) FROM levels WHERE level = 'B1') AS b1_levels,
(SELECT COUNT(*) FROM levels WHERE level = 'B2') AS b2_levels,
(SELECT COUNT(*) FROM levels WHERE level = 'C1') AS c1_levels,
(SELECT COUNT(*) FROM levels WHERE level = 'C2') AS c2_levels,
(SELECT COUNT(*) FROM sentences WHERE level = 'A1') AS a1_sent,
(SELECT COUNT(*) FROM sentences WHERE level = 'A2') AS a2_sent,
(SELECT COUNT(*) FROM sentences WHERE level = 'B1') AS b1_sent,
(SELECT COUNT(*) FROM sentences WHERE level = 'B2') AS b2_sent,
(SELECT COUNT(*) FROM sentences WHERE level = 'C1') AS c1_sent,
(SELECT COUNT(*) FROM sentences WHERE level = 'C2') AS c2_sent
""")
# 2. Очки и прогресс по всем уровням сразу — 4 запроса с GROUP BY
ws_rows = await conn.fetch("""
SELECT level,
COALESCE(SUM(score), 0) AS score,
COUNT(*) AS completed
FROM user_progress
WHERE user_id = $1 AND completed_at IS NOT NULL
GROUP BY level
""", telegram_id)
ws_by_level = {r["level"]: r for r in ws_rows}
s_rows = await conn.fetch("""
SELECT level,
COALESCE(SUM(score), 0) AS score,
COUNT(*) AS completed
FROM sentence_progress
WHERE user_id = $1 AND completed_at IS NOT NULL
GROUP BY level
""", telegram_id)
s_by_level = {r["level"]: r for r in s_rows}
wq_rows = await conn.fetch("""
SELECT level, COALESCE(total_score, 0) AS score
FROM word_quiz_progress
WHERE user_id = $1
""", telegram_id)
wq_by_level = {r["level"]: r for r in wq_rows}
bw_rows = await conn.fetch("""
SELECT level, COALESCE(total_score, 0) AS score
FROM bubble_words_progress
WHERE user_id = $1
""", telegram_id)
bw_by_level = {r["level"]: r for r in bw_rows}
# 3. Собираем level_stats в Python — без единого запроса
level_stats = {}
for lvl in LEVELS:
lvl_lower = lvl.lower()
ws_score = ws_by_level.get(lvl, {}).get("score", 0) or 0
ws_completed = ws_by_level.get(lvl, {}).get("completed", 0) or 0
s_score = s_by_level.get(lvl, {}).get("score", 0) or 0
s_completed = s_by_level.get(lvl, {}).get("completed", 0) or 0
wq_score = wq_by_level.get(lvl, {}).get("score", 0) or 0
bw_score = bw_by_level.get(lvl, {}).get("score", 0) or 0
total_ws_levels = ref_row[f"{lvl_lower}_levels"] or 0
total_sentences = ref_row[f"{lvl_lower}_sent"] or 0
total_items = total_ws_levels + total_sentences
completed_items = ws_completed + s_completed
progress_percent = round((completed_items / total_items * 100), 1) if total_items > 0 else 0
level_stats[lvl] = {
"total_score": ws_score + s_score + wq_score + bw_score,
"progress_percent": progress_percent,
"wordsearch_score": ws_score,
"sentence_score": s_score,
"wordquiz_score": wq_score,
"bubblewords_score": bw_score,
}
# 🔧 ИСПРАВЛЕНО: вызов достижений ВНЕ блока async with pool.acquire()
from routers.achievements import check_and_award_achievements
if my_position <= 3:
await check_and_award_achievements(telegram_id, "rank_reached", my_position)
return {
"leaderboard": leaderboard,
"current_level": current_level,
"my_rank": {
"rank": my_position,
"telegram_id": telegram_id,
"total_score": my_total,
"wordsearch_score": my_wordsearch,
"wordsearch_levels": my_wordsearch_levels,
"sentence_score": my_sentence,
"sentences_completed": my_sentences_completed,
"wordquiz_score": my_wordquiz,
"words_guessed": my_words_guessed,
"bubblewords_score": my_bubblewords,
"bubble_levels_completed": my_bubble_levels,
},
"level_stats": level_stats,
}
@router.get("/admin/stats")
async def get_admin_stats(request: Request):
"""Админ-статистика: только для ADMIN_IDS"""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
pool = await get_pool()
async with pool.acquire() as conn:
total_users = await conn.fetchval("SELECT COUNT(*) FROM users") or 0
new_today = await conn.fetchval(
"SELECT COUNT(*) FROM users WHERE created_at::date = CURRENT_DATE"
) or 0
online_now = await conn.fetchval("""
SELECT COUNT(DISTINCT user_id) FROM (
SELECT user_id FROM user_progress WHERE completed_at > NOW() - INTERVAL '30 minutes'
UNION
SELECT user_id FROM sentence_progress WHERE completed_at > NOW() - INTERVAL '30 minutes'
UNION
SELECT user_id FROM daily_logins WHERE login_date = CURRENT_DATE
) active
""") or 0
ws_players = await conn.fetchval(
"SELECT COUNT(DISTINCT user_id) FROM user_progress WHERE completed_at IS NOT NULL"
) or 0
s_players = await conn.fetchval(
"SELECT COUNT(DISTINCT user_id) FROM sentence_progress WHERE completed_at IS NOT NULL"
) or 0
wq_players = await conn.fetchval(
"SELECT COUNT(*) FROM word_quiz_progress WHERE words_guessed > 0"
) or 0
bw_players = await conn.fetchval(
"SELECT COUNT(*) FROM bubble_words_progress WHERE levels_completed > 0"
) or 0
# 🔧 ИСПРАВЛЕНО: 'open_flashcards' → 'open_teacher_menu'
# Раньше метрика всегда была 0, потому что такого кода достижения больше нет
teacher_menu_opened = await conn.fetchval("""
SELECT COUNT(*) FROM user_achievements ua
JOIN achievements a ON ua.achievement_id = a.id
WHERE a.code = 'open_teacher_menu'
""") or 0
# Количество пользователей по уровням
level_counts = {}
for lvl in LEVELS:
count = await conn.fetchval(
"SELECT COUNT(*) FROM users WHERE current_level = $1", lvl
) or 0
level_counts[lvl] = count
return {
"total_users": total_users,
"new_today": new_today,
"online_now": online_now,
"wordsearch_players": ws_players,
"sentence_players": s_players,
"wordquiz_players": wq_players,
"bubblewords_players": bw_players,
"teacher_menu_opened": teacher_menu_opened,
"level_counts": level_counts,
}
@router.get("/top")
async def get_top_players(request: Request):
"""Топ-3 игрока для главного экрана — из кэша"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT telegram_id, username, total_score
FROM leaderboard_cache
ORDER BY total_score DESC
LIMIT 3
""")
top3 = []
medals = ["🥇", "🥈", "🥉"]
for i, row in enumerate(rows):
top3.append({
"rank": i + 1,
"medal": medals[i],
"username": row["username"] or f"Player{row['telegram_id']}",
"total_score": row["total_score"],
})
return {"top3": top3}
+872
View File
@@ -0,0 +1,872 @@
"""
Learning Router — система уроков Filwords PL.
Уровни → Темы → Уроки → Финальный тест → Аналитика.
Данные в отдельных файлах: learning_data/level_*.py
"""
import logging
import json
import statistics
from datetime import datetime, timezone
import httpx
import re
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
from config import TOGETHER_API_KEY
from .learning_data import init_learning_data
from .learning_profile import get_weak_skills, get_strong_skills
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/learning", tags=["learning"])
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
ANALYTICS_MODEL = "MiniMaxAI/MiniMax-M3"
# Доступные функции приложения (для AI — чтобы не галлюцинировал)
AVAILABLE_FEATURES = [
"lessons",
"grammar_rules",
"cheat_sheets",
"word_game",
"sentence_game",
"choose_word",
"word_bubbles",
"pronunciation",
"ai_chat",
]
# Понятные названия разделов для пользователя
AVAILABLE_FEATURES_DESCRIPTION = """
Доступные разделы приложения (называй их ТАК, как они называются в интерфейсе пользователя):
- "Уроки" — уроки и тесты
- "Филворды" — игра в слова (найди слова в сетке букв)
- "Составь предложение" — составление предложений из слов
- "Выбери слово" — выбор правильного перевода
- "Пузыри слов" — сбор слов из пузырей
- "Произношение" — практика произношения
- "Грамматика" — правила и шпаргалки
- "AI-помощник" — чат с ИИ
НИКОГДА не используй технические названия: word_game, sentence_game, choose_word, word_bubbles, ai_chat, lessons, pronunciation, grammar_rules, cheat_sheets.
"""
async def get_skill_names(conn):
"""Загружает понятные названия навыков из БД (title_ru уроков)."""
rows = await conn.fetch("""
SELECT DISTINCT ON (skill_tag) skill_tag, title_ru
FROM learning_lessons
WHERE is_active = TRUE
ORDER BY skill_tag, order_number
""")
return {r["skill_tag"]: r["title_ru"] for r in rows}
async def get_weak_lessons(conn, skill_tags):
"""Находит уроки для перепрохождения по слабым навыкам."""
rows = await conn.fetch("""
SELECT DISTINCT ON (l.skill_tag) l.lesson_id, l.skill_tag, l.title_ru, t.name_ru as topic_name
FROM learning_lessons l
JOIN learning_topics t ON l.topic_id = t.topic_id
WHERE l.skill_tag = ANY($1) AND l.is_active = TRUE
ORDER BY l.skill_tag, l.order_number
""", skill_tags)
return [dict(r) for r in rows]
async def update_skill_mastery(conn, user_id: int, skill_tag: str):
"""Рассчитывает и обновляет мастерство по навыку на основе всех ответов пользователя"""
# Все ответы по навыку с question_type и wrong_because
answers = await conn.fetch("""
SELECT correct, time_taken_ms, hint_used, question_type, wrong_because, is_final_answer, created_at
FROM user_learning_answers
WHERE user_id = $1 AND skill_tag = $2
ORDER BY created_at ASC
""", user_id, skill_tag)
if not answers:
return None
total_attempts = len(answers)
correct_count = sum(1 for a in answers if a["correct"])
accuracy = (correct_count / total_attempts * 100) if total_attempts > 0 else 0
# Последние попытки
recent = answers[-5:]
recent_correct = sum(1 for a in recent if a["correct"])
recent_accuracy = (recent_correct / len(recent) * 100) if recent else 0
# last_3 / last_5 / last_10 accuracy
def calc_last_n(n):
last_n = answers[-n:] if len(answers) >= n else None
if not last_n or len(last_n) < n:
return None
correct = sum(1 for a in last_n if a["correct"])
return round((correct / n * 100), 2)
last_3_accuracy = calc_last_n(3)
last_5_accuracy = calc_last_n(5)
last_10_accuracy = calc_last_n(10)
# Время: среднее, медиана
all_times = [a["time_taken_ms"] for a in answers if a["time_taken_ms"] and a["time_taken_ms"] > 0]
correct_times = [a["time_taken_ms"] for a in answers if a["correct"] and a["time_taken_ms"] and a["time_taken_ms"] > 0]
wrong_times = [a["time_taken_ms"] for a in answers if not a["correct"] and a["time_taken_ms"] and a["time_taken_ms"] > 0]
avg_time_ms = int(sum(correct_times) / len(correct_times)) if correct_times else 0
median_time_ms = int(statistics.median(all_times)) if all_times else 0
median_correct_time_ms = int(statistics.median(correct_times)) if correct_times else 0
median_wrong_time_ms = int(statistics.median(wrong_times)) if wrong_times else 0
# Зависимость от подсказок
hints_used = sum(1 for a in answers if a["hint_used"])
hint_dependency = (hints_used / total_attempts * 100) if total_attempts > 0 else 0
# Разделение recognition / production / application
recognition_answers = [a for a in answers if a["question_type"] == "recognition"]
production_answers = [a for a in answers if a["question_type"] == "production"]
application_answers = [a for a in answers if a["question_type"] == "application"]
recognition_attempts = len(recognition_answers)
recognition_correct = sum(1 for a in recognition_answers if a["correct"])
production_attempts = len(production_answers)
production_correct = sum(1 for a in production_answers if a["correct"])
application_attempts = len(application_answers)
application_correct = sum(1 for a in application_answers if a["correct"])
recognition_score = round((recognition_correct / recognition_attempts * 100), 2) if recognition_attempts > 0 else None
production_score = round((production_correct / production_attempts * 100), 2) if production_attempts > 0 else None
application_score = round((application_correct / application_attempts * 100), 2) if application_attempts > 0 else None
# Passive / Active knowledge
passive_knowledge = recognition_score if recognition_score is not None else 0
active_scores = [s for s in [production_score, application_score] if s is not None]
active_knowledge = round(sum(active_scores) / len(active_scores), 2) if active_scores else 0
# Ошибки
error_count = sum(1 for a in answers if not a["correct"] and a["wrong_because"])
error_types = {}
for a in answers:
if not a["correct"] and a["wrong_because"]:
error_types[a["wrong_because"]] = error_types.get(a["wrong_because"], 0) + 1
repeated_error_count = sum(1 for count in error_types.values() if count >= 2)
# Дней с последней попытки
last_attempt = answers[-1]["created_at"]
if last_attempt:
now = datetime.now(timezone.utc)
if last_attempt.tzinfo is None:
last_attempt = last_attempt.replace(tzinfo=timezone.utc)
days_since = (now - last_attempt).days
else:
days_since = 0
# Мастерство
no_hint_score = max(0, 100 - hint_dependency)
mastery = round(accuracy * 0.4 + recent_accuracy * 0.3 + no_hint_score * 0.3, 2)
# Получаем прошлый mastery для расчёта delta
previous_row = await conn.fetchrow("""
SELECT mastery_score, previous_mastery_score FROM user_skill_mastery
WHERE user_id = $1 AND skill_tag = $2
""", user_id, skill_tag)
previous_mastery = float(previous_row["mastery_score"]) if previous_row and previous_row["mastery_score"] else mastery
mastery_delta = round(mastery - previous_mastery, 2)
# Trend
if total_attempts < 3:
trend = "insufficient_data"
elif last_3_accuracy is not None and last_10_accuracy is not None:
if last_3_accuracy > last_10_accuracy + 5:
trend = "improving"
elif last_3_accuracy < last_10_accuracy - 5:
trend = "declining"
else:
trend = "stable"
elif last_3_accuracy is not None and accuracy is not None:
if last_3_accuracy > accuracy + 5:
trend = "improving"
elif last_3_accuracy < accuracy - 5:
trend = "declining"
else:
trend = "stable"
else:
trend = "stable"
# Retention status
if days_since == 0:
retention_status = "fresh"
elif days_since <= 3:
retention_status = "fresh"
elif days_since <= 7:
retention_status = "aging"
elif days_since <= 14:
retention_status = "needs_review"
else:
retention_status = "overdue"
# Fluency score: скорость + точность + автоматизация
speed_score = 100 if median_correct_time_ms and median_correct_time_ms < 5000 else (50 if median_correct_time_ms and median_correct_time_ms < 15000 else 20)
fluency = round(accuracy * 0.4 + active_knowledge * 0.3 + speed_score * 0.3, 2)
# Уверенность
if len(recent) >= 3:
if all(a["correct"] for a in recent[-3:]):
confidence = min(100, mastery + 15)
elif not any(a["correct"] for a in recent[-3:]):
confidence = max(0, mastery - 20)
else:
confidence = mastery
else:
confidence = mastery
confidence = round(min(100, max(0, confidence)), 2)
# Сохраняем в user_skill_mastery
await conn.execute("""
INSERT INTO user_skill_mastery (
user_id, skill_tag, attempts_count, correct_count,
recognition_attempts, recognition_correct, recognition_score,
production_attempts, production_correct, production_score,
application_attempts, application_correct, application_score,
retention_score, hint_dependency, average_time_ms, median_time_ms,
median_correct_time_ms, median_wrong_time_ms,
recent_accuracy, last_3_accuracy, last_5_accuracy, last_10_accuracy,
long_term_accuracy, mastery_score, previous_mastery_score, mastery_delta,
trend, retention_status, fluency_score, passive_knowledge_score, active_knowledge_score,
confidence, error_count, repeated_error_count, days_since_last_attempt, last_attempt_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 0, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, NOW())
ON CONFLICT (user_id, skill_tag) DO UPDATE SET
attempts_count = $3,
correct_count = $4,
recognition_attempts = $5,
recognition_correct = $6,
recognition_score = $7,
production_attempts = $8,
production_correct = $9,
production_score = $10,
application_attempts = $11,
application_correct = $12,
application_score = $13,
hint_dependency = $14,
average_time_ms = $15,
median_time_ms = $16,
median_correct_time_ms = $17,
median_wrong_time_ms = $18,
recent_accuracy = $19,
last_3_accuracy = $20,
last_5_accuracy = $21,
last_10_accuracy = $22,
long_term_accuracy = $23,
mastery_score = $24,
previous_mastery_score = $25,
mastery_delta = $26,
trend = $27,
retention_status = $28,
fluency_score = $29,
passive_knowledge_score = $30,
active_knowledge_score = $31,
confidence = $32,
error_count = $33,
repeated_error_count = $34,
days_since_last_attempt = $35,
last_attempt_at = NOW()
""", user_id, skill_tag, total_attempts, correct_count, recognition_attempts, recognition_correct, recognition_score, production_attempts, production_correct, production_score, application_attempts, application_correct, application_score, hint_dependency, avg_time_ms, median_time_ms, median_correct_time_ms, median_wrong_time_ms, recent_accuracy, last_3_accuracy, last_5_accuracy, last_10_accuracy, accuracy, mastery, previous_mastery, mastery_delta, trend, retention_status, fluency, passive_knowledge, active_knowledge, confidence, error_count, repeated_error_count, days_since)
return {
"skill_tag": skill_tag,
"attempts": total_attempts,
"accuracy": round(accuracy, 1),
"recent_accuracy": round(recent_accuracy, 1),
"recognition_score": recognition_score,
"production_score": production_score,
"application_score": application_score,
"mastery": mastery,
"mastery_delta": mastery_delta,
"trend": trend,
"retention_status": retention_status,
"fluency": fluency,
"passive_knowledge": passive_knowledge,
"active_knowledge": active_knowledge,
"confidence": confidence,
}
async def update_topic_progress(conn, user_id: int, topic_id: str):
"""Обновляет прогресс темы: lessons_total, lessons_completed, topic_mastery"""
# Общее количество уроков в теме
lessons_total = await conn.fetchval("""
SELECT COUNT(*) FROM learning_lessons WHERE topic_id = $1 AND is_active = TRUE
""", topic_id) or 0
# Количество завершённых уроков
lessons_completed = await conn.fetchval("""
SELECT COUNT(*) FROM user_lesson_progress WHERE user_id = $1 AND lesson_id IN (
SELECT lesson_id FROM learning_lessons WHERE topic_id = $2
) AND completed = TRUE
""", user_id, topic_id) or 0
# Тестовый результат
test_row = await conn.fetchrow("""
SELECT test_completed, test_score FROM user_topic_progress WHERE user_id = $1 AND topic_id = $2
""", user_id, topic_id)
test_completed = test_row["test_completed"] if test_row else False
test_score = float(test_row["test_score"]) if test_row and test_row["test_score"] else 0
# Среднее мастерство по навыкам темы
skills_avg = await conn.fetchval("""
SELECT COALESCE(AVG(mastery_score), 0) FROM user_skill_mastery
WHERE user_id = $1 AND skill_tag IN (
SELECT DISTINCT skill_tag FROM learning_questions WHERE topic_id = $2
)
""", user_id, topic_id) or 0
# Мастерство темы
if test_completed and test_score > 0:
topic_mastery = round(float(skills_avg) * 0.4 + float(test_score) * 0.6, 2)
else:
topic_mastery = round(float(skills_avg), 2)
# Обновляем
await conn.execute("""
INSERT INTO user_topic_progress (user_id, topic_id, lessons_total, lessons_completed, test_completed, test_score, topic_mastery)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, topic_id) DO UPDATE SET
lessons_total = $3,
lessons_completed = $4,
test_completed = $5,
test_score = $6,
topic_mastery = $7
""", user_id, topic_id, lessons_total, lessons_completed, test_completed, test_score, topic_mastery)
return {
"lessons_total": lessons_total,
"lessons_completed": lessons_completed,
"test_completed": test_completed,
"test_score": round(test_score, 1),
"topic_mastery": topic_mastery,
}
async def update_ai_context(conn, user_id: int):
"""Обновляет AI-контекст пользователя: сильные/слабые навыки, общее мастерство, прогресс уровня"""
# Получаем текущий уровень
current_level = await conn.fetchval("""
SELECT current_level FROM users WHERE telegram_id = $1
""", user_id) or "A1"
# Все темы пользователя
topics = await conn.fetch("""
SELECT topic_id, topic_mastery, test_completed FROM user_topic_progress WHERE user_id = $1
""", user_id)
completed_topics = sum(1 for t in topics if t["test_completed"])
completed_lessons = await conn.fetchval("""
SELECT COUNT(*) FROM user_lesson_progress WHERE user_id = $1 AND completed = TRUE
""", user_id) or 0
# Всего тем в уровне
level_topics_total = await conn.fetchval("""
SELECT COUNT(*) FROM learning_topics WHERE level_code = $1 AND is_active = TRUE
""", current_level) or 0
# Процент завершения уровня
level_completion_percent = round((completed_topics / level_topics_total * 100), 2) if level_topics_total > 0 else 0
# Общее мастерство
overall_mastery = await conn.fetchval("""
SELECT COALESCE(AVG(topic_mastery), 0) FROM user_topic_progress WHERE user_id = $1
""", user_id) or 0
overall_mastery = float(overall_mastery) if overall_mastery else 0
# Сильные и слабые навыки — используем общий источник
strong_skills = await get_strong_skills(conn, user_id, limit=8)
weak_skills = await get_weak_skills(conn, user_id, limit=5)
strong_skill_tags = [s["skill_tag"] for s in strong_skills]
weak_skill_tags = [s["skill_tag"] for s in weak_skills]
# Частые ошибки
errors = await conn.fetch("""
SELECT wrong_because, COUNT(*) as cnt
FROM user_learning_answers
WHERE user_id = $1 AND wrong_because IS NOT NULL AND correct = FALSE
GROUP BY wrong_because
ORDER BY cnt DESC
LIMIT 5
""", user_id)
recurring_errors = [e["wrong_because"] for e in errors]
# Обновляем ai_user_context
await conn.execute("""
INSERT INTO ai_user_context (user_id, current_level, completed_topics, level_topics_total, level_completion_percent, completed_lessons, overall_mastery, strong_skills, weak_skills, recurring_errors, analytics_enabled, last_analysis_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, NOW())
ON CONFLICT (user_id) DO UPDATE SET
current_level = $2,
completed_topics = $3,
level_topics_total = $4,
level_completion_percent = $5,
completed_lessons = $6,
overall_mastery = $7,
strong_skills = $8,
weak_skills = $9,
recurring_errors = $10,
analytics_enabled = TRUE,
last_analysis_at = NOW()
""", user_id, current_level, completed_topics, level_topics_total, level_completion_percent, completed_lessons, round(float(overall_mastery), 2), json.dumps(strong_skill_tags), json.dumps(weak_skill_tags), json.dumps(recurring_errors))
return {
"completed_topics": completed_topics,
"level_topics_total": level_topics_total,
"level_completion_percent": level_completion_percent,
"completed_lessons": completed_lessons,
"overall_mastery": round(float(overall_mastery), 2),
"strong_skills": strong_skill_tags,
"weak_skills": weak_skill_tags,
}
@router.get("/levels")
async def get_learning_levels(request: Request):
"""Получить все уровни обучения"""
await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM learning_levels WHERE is_active = TRUE ORDER BY sort_order")
return {"levels": [dict(row) for row in rows]}
@router.get("/topics")
async def get_topics(request: Request, level: str = "A1"):
"""Получить темы для уровня"""
await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM learning_topics WHERE level_code = $1 AND is_active = TRUE ORDER BY order_number", level)
return {"topics": [dict(row) for row in rows]}
@router.get("/topics/{topic_id}")
async def get_topic(request: Request, topic_id: str):
"""Получить информацию о теме и список уроков"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
topic = await conn.fetchrow("SELECT * FROM learning_topics WHERE topic_id = $1", topic_id)
if not topic:
raise HTTPException(404, "Topic not found")
lessons = await conn.fetch("SELECT * FROM learning_lessons WHERE topic_id = $1 AND is_active = TRUE ORDER BY order_number", topic_id)
progress = await conn.fetchrow("SELECT * FROM user_topic_progress WHERE user_id = $1 AND topic_id = $2", telegram_id, topic_id)
lesson_progress = await conn.fetch("""
SELECT lesson_id, completed FROM user_lesson_progress WHERE user_id = $1
""", telegram_id)
progress_map = {lp["lesson_id"]: lp["completed"] for lp in lesson_progress}
lessons_data = []
for lesson in lessons:
l = dict(lesson)
l["completed"] = progress_map.get(lesson["lesson_id"], False)
lessons_data.append(l)
return {
"topic": dict(topic),
"lessons": lessons_data,
"progress": dict(progress) if progress else None,
}
@router.get("/lessons/{lesson_id}")
async def get_lesson(request: Request, lesson_id: str):
"""Получить урок с примерами и вопросами"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
lesson = await conn.fetchrow("SELECT * FROM learning_lessons WHERE lesson_id = $1", lesson_id)
if not lesson:
raise HTTPException(404, "Lesson not found")
examples = await conn.fetch("SELECT * FROM lesson_examples WHERE lesson_id = $1 ORDER BY order_number", lesson_id)
questions = await conn.fetch("SELECT * FROM learning_questions WHERE lesson_id = $1 AND is_test_question = FALSE ORDER BY id", lesson_id)
questions_data = []
for q in questions:
options = await conn.fetch("SELECT * FROM question_options WHERE question_id = $1", q["question_id"])
questions_data.append({
"question": dict(q),
"options": [dict(o) for o in options],
})
progress = await conn.fetchrow("SELECT * FROM user_lesson_progress WHERE user_id = $1 AND lesson_id = $2", telegram_id, lesson_id)
return {
"lesson": dict(lesson),
"examples": [dict(e) for e in examples],
"questions": questions_data,
"progress": dict(progress) if progress else None,
}
@router.get("/test/{topic_id}")
async def get_topic_test(request: Request, topic_id: str):
"""Получить вопросы финального теста"""
await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
questions = await conn.fetch("SELECT * FROM learning_questions WHERE topic_id = $1 AND is_test_question = TRUE ORDER BY test_order", topic_id)
questions_data = []
for q in questions:
options = await conn.fetch("SELECT * FROM question_options WHERE question_id = $1", q["question_id"])
questions_data.append({
"question": dict(q),
"options": [dict(o) for o in options],
})
return {"test": questions_data, "total": len(questions_data)}
@router.post("/session/start")
async def start_session(request: Request):
"""Начать новую сессию обучения"""
telegram_id, _ = await require_auth(request)
body = await request.json()
level_code = body.get("level_code", "A1")
topic_id = body.get("topic_id", "")
session_type = body.get("session_type", "lesson")
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO learning_sessions (user_id, level_code, topic_id, session_type)
VALUES ($1, $2, $3, $4) RETURNING id
""", telegram_id, level_code, topic_id, session_type)
return {"session_id": row["id"]}
@router.post("/answer")
async def save_answer(request: Request):
"""Сохранить ответ пользователя"""
telegram_id, _ = await require_auth(request)
body = await request.json()
session_id = body.get("session_id")
question_id = body.get("question_id")
selected_answer = body.get("answer", "")
time_taken_ms = body.get("time_taken_ms", 0)
is_final = body.get("is_final_answer", True)
if not session_id or not question_id:
raise HTTPException(400, "session_id and question_id required")
pool = await get_pool()
async with pool.acquire() as conn:
question = await conn.fetchrow("SELECT * FROM learning_questions WHERE question_id = $1", question_id)
if not question:
raise HTTPException(404, "Question not found")
correct = selected_answer.strip().lower() == (question["correct_answer"] or "").strip().lower()
wrong_because = None
if not correct:
option = await conn.fetchrow("""
SELECT wrong_because FROM question_options
WHERE question_id = $1 AND option_key = $2
""", question_id, selected_answer)
if option:
wrong_because = option["wrong_because"]
session = await conn.fetchrow("SELECT * FROM learning_sessions WHERE id = $1", session_id)
await conn.execute("""
INSERT INTO user_learning_answers (user_id, session_id, level_code, topic_id, lesson_id, question_id, selected_answer, correct, skill_tag, question_type, context_type, difficulty, time_taken_ms, hint_used, wrong_because, is_final_answer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
""", telegram_id, session_id, session["level_code"], session["topic_id"], question["lesson_id"], question_id, selected_answer, correct, question["skill_tag"], question["question_type"], question["context_type"], question["difficulty"], time_taken_ms, False, wrong_because, is_final)
return {"correct": correct, "wrong_because": wrong_because}
@router.post("/session/complete")
async def complete_session(request: Request):
"""Завершить сессию: обновляет прогресс урока/теста, мастерство навыков, прогресс темы и AI-контекст"""
telegram_id, _ = await require_auth(request)
body = await request.json()
session_id = body.get("session_id")
pool = await get_pool()
async with pool.acquire() as conn:
session = await conn.fetchrow("SELECT * FROM learning_sessions WHERE id = $1 AND user_id = $2", session_id, telegram_id)
if not session:
raise HTTPException(404, "Session not found")
await conn.execute("UPDATE learning_sessions SET is_completed = TRUE, completed_at = NOW() WHERE id = $1", session_id)
stats = await conn.fetchrow("""
SELECT COUNT(*) as total, COALESCE(SUM(CASE WHEN correct THEN 1 ELSE 0 END), 0) as correct_count
FROM user_learning_answers WHERE session_id = $1 AND is_final_answer = TRUE
""", session_id)
topic_id = session["topic_id"]
# Обновляем прогресс урока
if session["session_type"] == "lesson":
lesson_row = await conn.fetchrow("""
SELECT DISTINCT lesson_id FROM user_learning_answers WHERE session_id = $1 AND lesson_id IS NOT NULL
""", session_id)
if lesson_row and lesson_row["lesson_id"]:
lesson_id = lesson_row["lesson_id"]
score = (stats["correct_count"] / stats["total"] * 100) if stats["total"] > 0 else 0
await conn.execute("""
INSERT INTO user_lesson_progress (user_id, lesson_id, started_at, completed_at, attempts_count, correct_count, best_score, completed)
VALUES ($1, $2, NOW(), NOW(), 1, $3, $4, TRUE)
ON CONFLICT (user_id, lesson_id) DO UPDATE
SET completed_at = NOW(),
attempts_count = user_lesson_progress.attempts_count + 1,
correct_count = user_lesson_progress.correct_count + $3,
best_score = GREATEST(user_lesson_progress.best_score, $4),
completed = TRUE
""", telegram_id, lesson_id, stats["correct_count"], score)
# Обновляем прогресс темы если это тест
if session["session_type"] == "topic_test":
test_score = (stats["correct_count"] / stats["total"] * 100) if stats["total"] > 0 else 0
await conn.execute("""
INSERT INTO user_topic_progress (user_id, topic_id, test_started, test_completed, test_score, completed_at)
VALUES ($1, $2, TRUE, TRUE, $3, NOW())
ON CONFLICT (user_id, topic_id) DO UPDATE
SET test_completed = TRUE,
test_score = $3,
completed_at = NOW()
""", telegram_id, topic_id, test_score)
# Обновляем мастерство по всем навыкам из этой сессии
skill_tags = await conn.fetch("""
SELECT DISTINCT skill_tag FROM user_learning_answers WHERE session_id = $1
""", session_id)
for st in skill_tags:
await update_skill_mastery(conn, telegram_id, st["skill_tag"])
# Обновляем прогресс темы ТОЛЬКО для lesson и topic_test
if session["session_type"] in ("lesson", "topic_test") and topic_id:
await update_topic_progress(conn, telegram_id, topic_id)
# Обновляем AI-контекст (всегда)
await update_ai_context(conn, telegram_id)
return {"status": "ok", "total": stats["total"], "correct_count": stats["correct_count"]}
@router.get("/progress")
async def get_learning_progress(request: Request):
"""Получить прогресс пользователя по всем темам"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
topics = await conn.fetch("""
SELECT tp.*, up.lessons_completed, up.lessons_total, up.test_completed, up.test_score, up.topic_mastery
FROM learning_topics tp
LEFT JOIN user_topic_progress up ON tp.topic_id = up.topic_id AND up.user_id = $1
ORDER BY tp.level_code, tp.order_number
""", telegram_id)
return {"progress": [dict(t) for t in topics]}
@router.post("/analytics")
async def get_analytics(request: Request):
"""Анализирует результаты пользователя через AI на основе user_skill_mastery и user_topic_progress"""
telegram_id, _ = await require_auth(request)
if not TOGETHER_API_KEY:
raise HTTPException(500, "AI API key not configured")
pool = await get_pool()
async with pool.acquire() as conn:
# Получаем слабые навыки из ОБЩЕГО источника
weak_skills = await get_weak_skills(conn, telegram_id, limit=5)
if not weak_skills:
return {"analytics_enabled": False, "message": "Отличная работа! У тебя нет слабых навыков."}
# Загружаем названия навыков из БД
skill_names = await get_skill_names(conn)
skill_tag_lines = '\n'.join([f'- {tag}"{name}"' for tag, name in skill_names.items()])
# Находим уроки для перепрохождения
weak_skill_tags = [s["skill_tag"] for s in weak_skills]
weak_lessons = await get_weak_lessons(conn, weak_skill_tags)
weak_lessons_lines = '\n'.join([f'- {l["title_ru"]} (тема: {l["topic_name"]})' for l in weak_lessons])
# Формируем профиль
weak_skills_json = []
for s in weak_skills:
weak_skills_json.append({
"skill": s["skill_tag"],
"mastery": round(float(s["mastery_score"]), 1) if s["mastery_score"] else 0,
"repeated_errors": s["repeated_error_count"] or 0,
"retention": s["retention_status"] or "fresh",
"trend": s["trend"] or "stable",
"recent_accuracy": round(float(s["recent_accuracy"]), 1) if s["recent_accuracy"] else 0,
})
user_profile = {
"weak_skills": weak_skills_json,
"weak_lessons": [dict(l) for l in weak_lessons],
}
system_prompt = f"""Ты — AI-наставник польского языка в приложении Filwords PL.
Проанализируй слабые стороны пользователя и скажи что нужно перепройти.
Понятные названия навыков (используй ИХ в ответе, а не технические ID):
{skill_tag_lines}
ПРАВИЛА:
1. Не используй markdown
2. Отвечай на языке пользователя
3. НЕ говори о сильных сторонах — только о слабых
4. Говори ТОЛЬКО о тех навыках, которые перечислены в weak_skills
5. Для каждого слабого навыка укажи конкретный урок для перепрохождения
6. НАЗВАНИЯ НАВЫКОВ: всегда заменяй технические ID на понятные названия
7. ОБРАЩАЙСЯ К ПОЛЬЗОВАТЕЛЮ НА "ТЫ"
8. НИКОГДА не пиши имена метрик: mastery, recent_accuracy, retention_status и т.п. — только русский перевод
9. НЕ упоминай процент завершения уровня или количество пройденных тем
10. В конце предложи нажать кнопку "Потренировать слабые темы" — она уже есть под этим сообщением."""
user_prompt = f"""Слабые навыки пользователя:
{json.dumps(user_profile, ensure_ascii=False, indent=2)}
Напиши:
1. Какие темы ты не понял (конкретные навыки, понятными названиями)
2. Какие уроки нужно перепройти:
{weak_lessons_lines}
3. Нажми кнопку "Потренировать слабые темы" для практики
В конце напиши: "Готов потренироваться? Нажимай кнопку ниже!\""""
async with httpx.AsyncClient(timeout=90.0) as client:
response = await client.post(
TOGETHER_API_URL,
headers={
"Authorization": f"Bearer {TOGETHER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": ANALYTICS_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"max_tokens": 1000,
"temperature": 0.7,
},
)
if response.status_code != 200:
raise HTTPException(500, f"AI API error: {response.status_code}")
data = response.json()
ai_response = data["choices"][0]["message"]["content"].strip()
ai_response = ai_response.replace("**", "").replace("*", "").replace("#", "")
# Убираем технические ID в скобках
ai_response = re.sub(r'\s*\([a-z_]+_[a-z_]+\)', '', ai_response)
# Если ответ пустой - запасной текст
if not ai_response or len(ai_response) < 10:
ai_response = "Перепройди уроки по слабым темам. Нажми кнопку ниже, чтобы потренироваться!"
return {
"analytics_enabled": True,
"response": ai_response,
"weak_skills": weak_skills_json,
"has_practice_available": len(weak_skills_json) > 0,
"cta_label": "Потренировать слабые темы",
}
@router.get("/ai/context")
async def get_ai_context(request: Request):
"""Получить AI-контекст пользователя"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
context = await conn.fetchrow("SELECT * FROM ai_user_context WHERE user_id = $1", telegram_id)
if not context:
return {"analytics_enabled": False, "message": "Пройди первую тему, и я смогу начать анализировать твой прогресс."}
return {"context": dict(context)}
@router.post("/practice/start")
async def start_weak_skills_practice(request: Request):
"""Собирает сессию практики из реального банка вопросов по слабым навыкам.
Ноль обращений к LLM - только SQL."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
weak = await get_weak_skills(conn, telegram_id, limit=5)
if not weak:
return {"has_weak_skills": False, "message": "Нет слабых навыков - отличная работа!"}
skill_tags = [s["skill_tag"] for s in weak]
# Забираем краткое объяснение правила для каждого слабого навыка
explanations = await conn.fetch("""
SELECT DISTINCT ON (skill_tag) skill_tag, explanation_ru, explanation_uk, explanation_en, explanation_pl
FROM learning_lessons
WHERE skill_tag = ANY($1)
ORDER BY skill_tag, order_number
""", skill_tags)
explanations_map = {e["skill_tag"]: dict(e) for e in explanations}
# Вопросы вперемешку по всем слабым навыкам, только не тестовые
questions = await conn.fetch("""
SELECT * FROM learning_questions
WHERE skill_tag = ANY($1) AND is_test_question = FALSE
ORDER BY random()
LIMIT 10
""", skill_tags)
if not questions:
return {"has_weak_skills": True, "questions": [], "message": "Для этих навыков пока нет вопросов в базе."}
# Получаем уровень пользователя
user_level = await conn.fetchval("""
SELECT current_level FROM users WHERE telegram_id = $1
""", telegram_id) or "A1"
session = await conn.fetchrow("""
INSERT INTO learning_sessions (user_id, level_code, topic_id, session_type)
VALUES ($1, $2, '', 'weak_skills_practice') RETURNING id
""", telegram_id, user_level)
questions_data = []
for q in questions:
options = await conn.fetch(
"SELECT * FROM question_options WHERE question_id = $1", q["question_id"]
)
questions_data.append({"question": dict(q), "options": [dict(o) for o in options]})
# Загружаем понятные названия навыков из БД
skill_names = await get_skill_names(conn)
return {
"has_weak_skills": True,
"session_id": session["id"],
"explanations": [
{"skill_tag": tag, "name": skill_names.get(tag, tag), **explanations_map.get(tag, {})}
for tag in skill_tags
],
"questions": questions_data,
}
+40
View File
@@ -0,0 +1,40 @@
"""
Learning Data Package — данные всех уровней.
Каждый уровень в отдельном файле.
"""
import logging
from database import get_pool
from . import level_a1, level_a2, level_b1, level_b2, level_c1, level_c2
logger = logging.getLogger(__name__)
async def init_learning_data():
"""Инициализирует уровни и все темы"""
pool = await get_pool()
async with pool.acquire() as conn:
# ============ УРОВНИ ============
levels = [
("A1", "Poziom A1", "Уровень A1", "Рівень A1", "Level A1", 1),
("A2", "Poziom A2", "Уровень A2", "Рівень A2", "Level A2", 2),
("B1", "Poziom B1", "Уровень B1", "Рівень B1", "Level B1", 3),
("B2", "Poziom B2", "Уровень B2", "Рівень B2", "Level B2", 4),
("C1", "Poziom C1", "Уровень C1", "Рівень C1", "Level C1", 5),
("C2", "Poziom C2", "Уровень C2", "Рівень C2", "Level C2", 6),
]
for code, pl, ru, uk, en, sort in levels:
await conn.execute("""
INSERT INTO learning_levels (code, name_pl, name_ru, name_uk, name_en, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (code) DO NOTHING
""", code, pl, ru, uk, en, sort)
# Загружаем данные каждого уровня
await level_a1.init_level_a1(conn)
await level_a2.init_level_a2(conn)
await level_b1.init_level_b1(conn)
await level_b2.init_level_b2(conn)
await level_c1.init_level_c1(conn)
await level_c2.init_level_c2(conn)
logger.info("Learning data fully initialized")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
async def init_level_b1(conn):
"""Уровень B1 — пока пусто"""
pass
+3
View File
@@ -0,0 +1,3 @@
async def init_level_b2(conn):
"""Уровень B2 — пока пусто"""
pass
+3
View File
@@ -0,0 +1,3 @@
async def init_level_c1(conn):
"""Уровень C1 — пока пусто"""
pass
+3
View File
@@ -0,0 +1,3 @@
async def init_level_c2(conn):
"""Уровень C2 — пока пусто"""
pass
+56
View File
@@ -0,0 +1,56 @@
"""
Единый профиль ученика — источник правды для аналитики и повторения.
Никакой LLM здесь нет — только детерминированная бизнес-логика.
"""
import hashlib
import json
from decimal import Decimal
async def get_weak_skills(conn, user_id: int, limit: int = 5):
"""Получить слабые навыки (mastery < 75 или needs_review/overdue или repeated_error).
Одинаково используется в /analytics и /review."""
rows = await conn.fetch("""
SELECT skill_tag, mastery_score, repeated_error_count,
retention_status, trend, recent_accuracy,
recognition_score, production_score, application_score,
median_correct_time_ms, days_since_last_attempt
FROM user_skill_mastery
WHERE user_id = $1 AND attempts_count >= 2
AND (mastery_score < 75
OR retention_status IN ('needs_review', 'overdue')
OR repeated_error_count > 0)
ORDER BY mastery_score ASC
LIMIT $2
""", user_id, limit)
return [dict(r) for r in rows]
async def get_strong_skills(conn, user_id: int, limit: int = 8):
"""Получить сильные навыки (mastery >= 75)."""
rows = await conn.fetch("""
SELECT skill_tag, mastery_score FROM user_skill_mastery
WHERE user_id = $1 AND attempts_count >= 2 AND mastery_score >= 75
ORDER BY mastery_score DESC
LIMIT $2
""", user_id, limit)
return [dict(r) for r in rows]
def get_profile_hash(weak_skills: list) -> str:
"""Хэш списка слабых навыков — для инвалидации кэша."""
# Конвертируем Decimal в float для JSON
serializable = []
for s in weak_skills:
item = {}
for k, v in s.items():
if isinstance(v, Decimal):
item[k] = float(v)
else:
item[k] = v
serializable.append(item)
canonical = json.dumps(serializable, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
+286
View File
@@ -0,0 +1,286 @@
"""
Orchestrator — мозг чат-учителя.
Выбирает следующий урок для пользователя на основе:
1. retention_status = 'overdue' или 'needs_review' → повторение
2. mastery_score < 75 → повторение слабого навыка
3. Иначе → следующий урок по порядку
Никакого AI — только детерминированная логика.
"""
import logging
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
async def get_next_lesson(conn, user_id: int) -> Dict[str, Any]:
"""
Выбирает следующий урок для пользователя.
Возвращает:
{
"type": "new" | "review" | "test",
"lesson": {...} | None,
"reason": str,
"skill_tag": str | None
}
"""
# Получаем текущий уровень пользователя
current_level = await conn.fetchval("""
SELECT current_level FROM users WHERE telegram_id = $1
""", user_id) or "A1"
# ============ ШАГ 1: Проверяем retention_status ============
# Если есть навыки, которые давно не повторяли — повторяем их
overdue_skill = await conn.fetchrow("""
SELECT skill_tag, mastery_score, days_since_last_attempt, retention_status
FROM user_skill_mastery
WHERE user_id = $1
AND retention_status IN ('overdue', 'needs_review')
AND attempts_count >= 2
ORDER BY days_since_last_attempt DESC
LIMIT 1
""", user_id)
if overdue_skill:
lesson = await conn.fetchrow("""
SELECT l.lesson_id, l.topic_id, l.skill_tag, l.title_pl, l.title_ru, l.title_uk, l.title_en
FROM learning_lessons l
WHERE l.skill_tag = $1
AND l.is_active = TRUE
ORDER BY l.order_number
LIMIT 1
""", overdue_skill["skill_tag"])
if lesson:
logger.info(f"Orchestrator: review (overdue) for user={user_id}, skill={overdue_skill['skill_tag']}")
return {
"type": "review",
"lesson": dict(lesson),
"reason": "overdue",
"skill_tag": overdue_skill["skill_tag"],
}
# ============ ШАГ 2: Проверяем mastery < 75 ============
weak_skill = await conn.fetchrow("""
SELECT skill_tag, mastery_score, repeated_error_count, trend
FROM user_skill_mastery
WHERE user_id = $1
AND mastery_score < 75
AND attempts_count >= 2
ORDER BY mastery_score ASC
LIMIT 1
""", user_id)
if weak_skill:
lesson = await conn.fetchrow("""
SELECT l.lesson_id, l.topic_id, l.skill_tag, l.title_pl, l.title_ru, l.title_uk, l.title_en
FROM learning_lessons l
WHERE l.skill_tag = $1
AND l.is_active = TRUE
ORDER BY l.order_number
LIMIT 1
""", weak_skill["skill_tag"])
if lesson:
logger.info(f"Orchestrator: review (weak) for user={user_id}, skill={weak_skill['skill_tag']}, mastery={weak_skill['mastery_score']}")
return {
"type": "review",
"lesson": dict(lesson),
"reason": "weak_mastery",
"skill_tag": weak_skill["skill_tag"],
}
# ============ ШАГ 3: Проверяем, нужно ли пройти тест темы ============
# Если пользователь завершил все уроки темы, но не прошёл тест
topic_for_test = await conn.fetchrow("""
SELECT tp.topic_id, tp.lessons_total, tp.lessons_completed, tp.test_completed
FROM user_topic_progress tp
WHERE tp.user_id = $1
AND tp.lessons_completed >= tp.lessons_total
AND tp.lessons_total > 0
AND tp.test_completed = FALSE
ORDER BY tp.topic_id
LIMIT 1
""", user_id)
if topic_for_test:
logger.info(f"Orchestrator: test for user={user_id}, topic={topic_for_test['topic_id']}")
return {
"type": "test",
"lesson": None,
"reason": "topic_completed_no_test",
"topic_id": topic_for_test["topic_id"],
}
# ============ ШАГ 4: Следующий урок по порядку ============
# Находим первый непройденный урок в текущем уровне
next_lesson = await conn.fetchrow("""
SELECT l.lesson_id, l.topic_id, l.skill_tag, l.title_pl, l.title_ru, l.title_uk, l.title_en,
t.topic_id as topic_id, t.order_number as topic_order
FROM learning_lessons l
JOIN learning_topics t ON l.topic_id = t.topic_id
WHERE t.level_code = $1
AND l.is_active = TRUE
AND l.lesson_id NOT IN (
SELECT lesson_id FROM user_lesson_progress
WHERE user_id = $2 AND completed = TRUE
)
ORDER BY t.order_number, l.order_number
LIMIT 1
""", current_level, user_id)
if next_lesson:
logger.info(f"Orchestrator: new lesson for user={user_id}, lesson={next_lesson['lesson_id']}")
return {
"type": "new",
"lesson": dict(next_lesson),
"reason": "next_in_order",
"skill_tag": next_lesson["skill_tag"],
}
# ============ ШАГ 5: Всё пройдено ============
logger.info(f"Orchestrator: all completed for user={user_id}")
return {
"type": "completed",
"lesson": None,
"reason": "all_completed",
"skill_tag": None,
}
async def get_lesson_by_id(conn, lesson_id: str) -> Optional[Dict[str, Any]]:
"""Получает урок по ID со всеми данными: объяснение, примеры, вопросы."""
lesson = await conn.fetchrow("""
SELECT * FROM learning_lessons WHERE lesson_id = $1 AND is_active = TRUE
""", lesson_id)
if not lesson:
return None
examples = await conn.fetch("""
SELECT * FROM lesson_examples WHERE lesson_id = $1 ORDER BY order_number
""", lesson_id)
questions = await conn.fetch("""
SELECT * FROM learning_questions
WHERE lesson_id = $1 AND is_test_question = FALSE
ORDER BY id
""", lesson_id)
questions_data = []
for q in questions:
options = await conn.fetch("""
SELECT * FROM question_options WHERE question_id = $1
""", q["question_id"])
questions_data.append({
"question": dict(q),
"options": [dict(o) for o in options],
})
return {
"lesson": dict(lesson),
"examples": [dict(e) for e in examples],
"questions": questions_data,
}
async def get_lesson_questions(conn, lesson_id: str) -> list:
"""Получает только вопросы урока (без объяснения)."""
questions = await conn.fetch("""
SELECT * FROM learning_questions
WHERE lesson_id = $1 AND is_test_question = FALSE
ORDER BY id
""", lesson_id)
questions_data = []
for q in questions:
options = await conn.fetch("""
SELECT * FROM question_options WHERE question_id = $1
""", q["question_id"])
questions_data.append({
"question": dict(q),
"options": [dict(o) for o in options],
})
return questions_data
async def get_test_questions(conn, topic_id: str) -> list:
"""Получает вопросы финального теста по теме."""
questions = await conn.fetch("""
SELECT * FROM learning_questions
WHERE topic_id = $1 AND is_test_question = TRUE
ORDER BY test_order
""", topic_id)
questions_data = []
for q in questions:
options = await conn.fetch("""
SELECT * FROM question_options WHERE question_id = $1
""", q["question_id"])
questions_data.append({
"question": dict(q),
"options": [dict(o) for o in options],
})
return questions_data
async def get_random_reaction(conn, reaction_type: str, lang: str) -> str:
"""Получает случайную реакцию из банка по типу."""
row = await conn.fetchrow("""
SELECT text_pl, text_ru, text_uk, text_en
FROM teacher_reactions
WHERE reaction_type = $1
ORDER BY RANDOM()
LIMIT 1
""", reaction_type)
if not row:
# Запасные реакции, если таблица пуста
fallbacks = {
"correct": {
"pl": "Świetnie!",
"ru": "Отлично!",
"uk": "Чудово!",
"en": "Great!"
},
"almost": {
"pl": "Prawie!",
"ru": "Почти!",
"uk": "Майже!",
"en": "Almost!"
},
"wrong": {
"pl": "Nie tym razem.",
"ru": "Не в этот раз.",
"uk": "Не цього разу.",
"en": "Not this time."
},
}
return fallbacks.get(reaction_type, {}).get(lang, "OK")
field = f"text_{lang}"
return row.get(field) or row.get("text_en") or "OK"
async def get_wrong_because_explanation(conn, wrong_because: str, lang: str) -> str:
"""Получает объяснение ошибки по тегу wrong_because."""
row = await conn.fetchrow("""
SELECT explanation_pl, explanation_ru, explanation_uk, explanation_en
FROM wrong_because_explanations
WHERE wrong_because = $1
""", wrong_because)
if not row:
return ""
field = f"explanation_{lang}"
return row.get(field) or row.get("explanation_en") or ""
+46
View File
@@ -0,0 +1,46 @@
import random
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
router = APIRouter(prefix="/pronunciation", tags=["pronunciation"])
@router.get("/next")
async def get_next_word(request: Request):
"""Выдаёт случайное слово из всех уровней для раздела Произношение."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT app_language FROM users WHERE telegram_id = $1", telegram_id
)
if not user:
raise HTTPException(401, "User not registered")
lang = user["app_language"] or "pl"
# Берём случайное слово из всех уровней (без привязки к конкретному уровню)
word = await conn.fetchrow("""
SELECT word_pl, translation_ru, translation_uk, translation_en, level
FROM (
SELECT DISTINCT ON (word_pl) word_pl, translation_ru, translation_uk, translation_en, level
FROM level_words
WHERE level_id IS NULL
) AS unique_words
ORDER BY RANDOM()
LIMIT 1
""")
if not word:
raise HTTPException(404, "No words found")
translation_field = f"translation_{lang}"
translation = word.get(translation_field) or word.get("translation_en") or word["word_pl"]
return {
"word_pl": word["word_pl"],
"translation": translation,
"level": word["level"] or "A1",
}
+290
View File
@@ -0,0 +1,290 @@
import csv
import random
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
router = APIRouter(prefix="/sentences", tags=["sentences"])
@router.get("/{sentence_id}/hint")
async def get_sentence_hint(sentence_id: int, request: Request):
"""Получить правильное предложение в качестве подсказки (одноразово)"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT sentence_pl FROM sentences WHERE id = $1", sentence_id
)
if not row:
raise HTTPException(404, "Sentence not found")
return {
"sentence_pl": row["sentence_pl"],
}
def load_sentences_from_csv(filename):
"""Загружает предложения из CSV файла (формат: polish,english,russian,ukrainian)."""
sentences = []
with open(filename, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
sentences.append({
"sentence_pl": row["polish"].strip(),
"translation_ru": row.get("russian", ""),
"translation_uk": row.get("ukrainian", ""),
"translation_en": row.get("english", ""),
})
return sentences
async def init_sentences():
"""Инициализирует таблицу предложений из CSV файлов по уровням."""
pool = await get_pool()
async with pool.acquire() as conn:
level_files = [
("A1", "sentences_a1.csv"),
("A2", "sentences_a2.csv"),
("B1", "sentences_b1.csv"),
]
for level, filename in level_files:
sentences = load_sentences_from_csv(filename)
for i, s in enumerate(sentences):
words_count = len(s["sentence_pl"].split())
difficulty = 1 if words_count <= 4 else 2 if words_count <= 6 else 3
await conn.execute(
"""INSERT INTO sentences (sentence_number, sentence_pl, translation_ru, translation_uk, translation_en, difficulty, level)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (level, sentence_number) DO NOTHING""",
i + 1, s["sentence_pl"],
s["translation_ru"], s["translation_uk"], s["translation_en"],
difficulty, level
)
def normalize_word_for_display(word: str) -> str:
"""Убирает знаки конца предложения и приводит к нижнему регистру.
Запятые, точки с запятой, двоеточия и кавычки ОСТАЮТСЯ — игрок должен их видеть."""
return word.strip('.!?…').lower()
def normalize_word_for_check(word: str) -> str:
"""Убирает ВСЮ пунктуацию для сравнения — игрок не обязан расставлять запятые."""
return word.strip('.,!?;:()[]{}"\'-…').lower()
@router.get("/next")
async def get_next_sentence(request: Request):
"""Получить следующее непройденное предложение для выбранного уровня"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT app_language, current_level FROM users WHERE telegram_id = $1", telegram_id
)
if not user:
raise HTTPException(401, "User not registered")
lang = user["app_language"] or "pl"
current_level = user["current_level"] or "A1"
# Находим случайное непройденное предложение нужного уровня
row = await conn.fetchrow("""
SELECT s.* FROM sentences s
WHERE s.level = $1
AND s.id NOT IN (
SELECT sentence_id FROM sentence_progress WHERE user_id = $2
)
ORDER BY RANDOM()
LIMIT 1
""", current_level, telegram_id)
if not row:
return {"completed": True, "message": "All sentences completed!"}
# Оригинальные слова (для проверки)
correct_words = row["sentence_pl"].split()
# Слова для ОТОБРАЖЕНИЯ — запятые остаются, убираем только точки/воскл/вопрос
display_words = [normalize_word_for_display(w) for w in correct_words]
shuffled_words = display_words.copy()
random.shuffle(shuffled_words)
translation_field = f"translation_{lang}"
translation = row.get(translation_field, "") or row.get("translation_en", "")
return {
"id": row["id"],
"sentence_number": row["sentence_number"],
"words": shuffled_words,
"correct_words": display_words,
"translation": translation,
"difficulty": row["difficulty"],
"level": row["level"],
}
@router.post("/check")
async def check_sentence(request: Request):
"""Проверка предложения с защитой от повторного прохождения и выдачей достижений"""
telegram_id, _ = await require_auth(request)
body = await request.json()
sentence_id = body.get("sentence_id")
user_words = body.get("words", [])
completion_time = body.get("completion_time")
if not sentence_id or not user_words:
raise HTTPException(400, "sentence_id and words required")
if len(user_words) > 20:
raise HTTPException(400, "Too many words")
pool = await get_pool()
async with pool.acquire() as conn:
user_exists = await conn.fetchval(
"SELECT 1 FROM users WHERE telegram_id = $1", telegram_id
)
if not user_exists:
raise HTTPException(401, "User not registered")
row = await conn.fetchrow(
"SELECT id, sentence_pl, level FROM sentences WHERE id = $1", sentence_id
)
if not row:
raise HTTPException(404, "Sentence not found")
existing_progress = await conn.fetchrow(
"SELECT score, completed_at FROM sentence_progress WHERE user_id = $1 AND sentence_id = $2",
telegram_id, sentence_id
)
if existing_progress:
return {
"correct": True,
"correct_words": [normalize_word_for_display(w) for w in row["sentence_pl"].split()],
"already_completed": True,
"score": existing_progress["score"],
"completed_at": existing_progress["completed_at"].isoformat() if existing_progress["completed_at"] else None,
"new_achievements": []
}
# Защита от читерства: минимальное время 3 секунды
if completion_time is not None and completion_time < 3:
return {
"correct": False,
"correct_words": [normalize_word_for_display(w) for w in row["sentence_pl"].split()],
"already_completed": False,
"new_achievements": [],
"error": "Too fast. Please take your time."
}
# Нормализуем для СРАВНЕНИЯ — без запятых и прочей пунктуации
correct_words_original = row["sentence_pl"].split()
correct_words_normalized = [normalize_word_for_check(w) for w in correct_words_original]
user_words_normalized = [normalize_word_for_check(w) for w in user_words]
is_correct = user_words_normalized == correct_words_normalized
if is_correct:
word_count = len(correct_words_original)
if word_count <= 4:
score = 50
elif word_count <= 6:
score = 75
else:
score = 100
await conn.execute(
"""INSERT INTO sentence_progress (user_id, sentence_id, completed_at, score, level)
VALUES ($1, $2, NOW(), $3, $4)""",
telegram_id, sentence_id, score, row["level"],
)
from routers.achievements import check_and_award_achievements
new_achievements = []
sentences_completed = await conn.fetchval(
"SELECT COUNT(*) FROM sentence_progress WHERE user_id = $1",
telegram_id
)
ach = await check_and_award_achievements(telegram_id, "sentences_completed", sentences_completed)
new_achievements.extend(ach)
if completion_time is not None and completion_time > 0:
ach = await check_and_award_achievements(telegram_id, "speed_sentence", int(completion_time))
new_achievements.extend(ach)
# Общий счёт по ВСЕМ играм
wordsearch_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM user_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
sentence_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
wordquiz_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
bubblewords_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM bubble_words_progress WHERE user_id = $1",
telegram_id
) or 0
grand_total = wordsearch_score + sentence_score + wordquiz_score + bubblewords_score
ach = await check_and_award_achievements(telegram_id, "total_score", grand_total)
new_achievements.extend(ach)
return {
"correct": True,
"correct_words": [normalize_word_for_display(w) for w in correct_words_original],
"already_completed": False,
"score": score,
"new_achievements": new_achievements
}
return {
"correct": False,
"correct_words": [normalize_word_for_display(w) for w in correct_words_original],
"already_completed": False,
"new_achievements": []
}
@router.get("/stats")
async def get_sentence_stats(request: Request):
"""Статистика по предложениям для выбранного уровня"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT current_level FROM users WHERE telegram_id = $1", telegram_id
)
current_level = user["current_level"] if user else "A1"
total = await conn.fetchval(
"SELECT COUNT(*) FROM sentences WHERE level = $1", current_level
)
completed = await conn.fetchval(
"SELECT COUNT(*) FROM sentence_progress WHERE user_id = $1 AND level = $2",
telegram_id, current_level
)
total_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1 AND level = $2",
telegram_id, current_level
)
return {
"total_sentences": total,
"completed": completed or 0,
"remaining": total - (completed or 0),
"total_score": total_score or 0,
"progress_percent": round(((completed or 0) / total * 100), 1) if total > 0 else 0,
"level": current_level,
}
+359
View File
@@ -0,0 +1,359 @@
"""
Support Router — система поддержки пользователей.
Пользователи пишут админу о багах, неточностях и предложениях.
Админ отвечает. Обе стороны могут переводить сообщения через AI (MiniMax-M3).
"""
import logging
import httpx
import re
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
from config import TOGETHER_API_KEY, ADMIN_IDS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/support", tags=["support"])
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
SUPPORT_MODEL = "MiniMaxAI/MiniMax-M3"
async def call_ai_translate(text: str, target_lang: str) -> str:
"""Переводит текст через AI (MiniMax-M3)."""
if not TOGETHER_API_KEY:
return ""
try:
async with httpx.AsyncClient(timeout=90.0) as client:
response = await client.post(
TOGETHER_API_URL,
headers={
"Authorization": f"Bearer {TOGETHER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": SUPPORT_MODEL,
"messages": [
{"role": "system", "content": f"Ты — переводчик. Переведи текст на язык: {target_lang}. Отвечай только переводом без пояснений. Не используй markdown."},
{"role": "user", "content": text},
],
"max_tokens": 500,
"temperature": 0.3,
},
)
if response.status_code != 200:
logger.error(f"Translate API error: {response.status_code}")
return ""
data = response.json()
translated = data["choices"][0]["message"]["content"].strip()
translated = re.sub(r'\s*\([a-z_]+_[a-z_]+\)', '', translated)
return translated
except Exception as e:
logger.error(f"Translate error: {str(e)}")
return ""
async def get_user_lang(conn, user_id: int) -> str:
"""Получает язык интерфейса пользователя."""
lang = await conn.fetchval("""
SELECT app_language FROM users WHERE telegram_id = $1
""", user_id)
return lang if lang in ["pl", "ru", "uk", "en"] else "pl"
@router.post("/send")
async def send_support_message(request: Request):
"""
Пользователь отправляет сообщение в поддержку.
Если у пользователя нет открытого тикета — создаётся новый.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
message = body.get("message", "").strip()
if not message:
raise HTTPException(400, "message is required")
if len(message) > 1000:
raise HTTPException(400, "Message too long (max 1000 characters)")
pool = await get_pool()
async with pool.acquire() as conn:
# Ищем открытый тикет пользователя
ticket = await conn.fetchrow("""
SELECT id FROM support_tickets
WHERE user_id = $1 AND status = 'open'
ORDER BY id DESC
LIMIT 1
""", telegram_id)
ticket_id = None
if ticket:
ticket_id = ticket["id"]
# Обновляем updated_at
await conn.execute("""
UPDATE support_tickets SET updated_at = NOW() WHERE id = $1
""", ticket_id)
else:
# Создаём новый тикет
new_ticket = await conn.fetchrow("""
INSERT INTO support_tickets (user_id, status)
VALUES ($1, 'open') RETURNING id
""", telegram_id)
ticket_id = new_ticket["id"]
# Сохраняем сообщение (юзер написал — админ ещё не прочитал)
await conn.execute("""
INSERT INTO support_messages (ticket_id, sender_id, message, is_read)
VALUES ($1, $2, $3, FALSE)
""", ticket_id, telegram_id, message)
return {"status": "ok", "ticket_id": ticket_id}
@router.get("/user-tickets")
async def get_user_tickets(request: Request):
"""Пользователь получает список своих тикетов."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
tickets = await conn.fetch("""
SELECT id, status, created_at, updated_at
FROM support_tickets
WHERE user_id = $1
ORDER BY updated_at DESC
""", telegram_id)
return {"tickets": [dict(t) for t in tickets]}
@router.get("/user-messages/{ticket_id}")
async def get_user_messages(request: Request, ticket_id: int):
"""Пользователь получает сообщения своего тикета и отмечает ответы админа прочитанными."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем, что тикет принадлежит пользователю
ticket = await conn.fetchrow("""
SELECT id FROM support_tickets WHERE id = $1 AND user_id = $2
""", ticket_id, telegram_id)
if not ticket:
raise HTTPException(404, "Ticket not found")
# Отмечаем сообщения админа как прочитанные
await conn.execute("""
UPDATE support_messages SET is_read = TRUE
WHERE ticket_id = $1 AND sender_id != $2
""", ticket_id, telegram_id)
messages = await conn.fetch("""
SELECT id, sender_id, message, is_read, created_at
FROM support_messages
WHERE ticket_id = $1
ORDER BY id ASC
""", ticket_id)
return {"messages": [dict(m) for m in messages]}
@router.get("/user-unread-count")
async def get_user_unread_count(request: Request):
"""Пользователь получает количество непрочитанных ответов админа."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
count = await conn.fetchval("""
SELECT COUNT(*) FROM support_messages m
JOIN support_tickets t ON m.ticket_id = t.id
WHERE t.user_id = $1
AND m.is_read = FALSE
AND m.sender_id != $1
""", telegram_id)
return {"unread_count": count or 0}
@router.post("/delete-ticket/{ticket_id}")
async def delete_ticket(request: Request, ticket_id: int):
"""
Удаляет тикет.
Может удалить: сам пользователь (свой тикет) или админ (любой).
"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем права
if telegram_id in ADMIN_IDS:
# Админ может удалить любой
result = await conn.execute("DELETE FROM support_tickets WHERE id = $1", ticket_id)
else:
# Пользователь может удалить только свой
result = await conn.execute("""
DELETE FROM support_tickets WHERE id = $1 AND user_id = $2
""", ticket_id, telegram_id)
affected = int(result.split()[-1])
if affected == 0:
raise HTTPException(404, "Ticket not found or no permission")
return {"status": "ok"}
# ============ АДМИНСКИЕ ЭНДПОИНТЫ ============
@router.get("/admin-tickets")
async def get_admin_tickets(request: Request):
"""Админ получает список всех тикетов."""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
pool = await get_pool()
async with pool.acquire() as conn:
tickets = await conn.fetch("""
SELECT
t.id,
t.user_id,
t.status,
t.created_at,
t.updated_at,
u.username,
u.app_language,
(SELECT COUNT(*) FROM support_messages m WHERE m.ticket_id = t.id AND m.is_read = FALSE) as unread_count,
(SELECT message FROM support_messages m WHERE m.ticket_id = t.id ORDER BY m.id DESC LIMIT 1) as last_message
FROM support_tickets t
LEFT JOIN users u ON t.user_id = u.telegram_id
ORDER BY t.updated_at DESC
""")
return {"tickets": [dict(t) for t in tickets]}
@router.get("/admin-messages/{ticket_id}")
async def get_admin_messages(request: Request, ticket_id: int):
"""Админ получает сообщения тикета и отмечает их как прочитанные."""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
pool = await get_pool()
async with pool.acquire() as conn:
ticket = await conn.fetchrow("""
SELECT id FROM support_tickets WHERE id = $1
""", ticket_id)
if not ticket:
raise HTTPException(404, "Ticket not found")
# Отмечаем сообщения пользователя как прочитанные
await conn.execute("""
UPDATE support_messages SET is_read = TRUE
WHERE ticket_id = $1 AND sender_id != $2
""", ticket_id, telegram_id)
messages = await conn.fetch("""
SELECT id, sender_id, message, is_read, created_at
FROM support_messages
WHERE ticket_id = $1
ORDER BY id ASC
""", ticket_id)
return {"messages": [dict(m) for m in messages]}
@router.post("/admin-reply")
async def admin_reply(request: Request):
"""Админ отвечает пользователю."""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
body = await request.json()
ticket_id = body.get("ticket_id")
message = body.get("message", "").strip()
if not ticket_id or not message:
raise HTTPException(400, "ticket_id and message required")
if len(message) > 1000:
raise HTTPException(400, "Message too long (max 1000 characters)")
pool = await get_pool()
async with pool.acquire() as conn:
ticket = await conn.fetchrow("""
SELECT id FROM support_tickets WHERE id = $1
""", ticket_id)
if not ticket:
raise HTTPException(404, "Ticket not found")
# 🔧 ИСПРАВЛЕНО: админ написал — юзер ЕЩЁ НЕ ПРОЧИТАЛ, поэтому is_read = FALSE
# Раньше было TRUE, из-за чего бейдж непрочитанных у пользователя не появлялся
await conn.execute("""
INSERT INTO support_messages (ticket_id, sender_id, message, is_read)
VALUES ($1, $2, $3, FALSE)
""", ticket_id, telegram_id, message)
await conn.execute("""
UPDATE support_tickets SET updated_at = NOW() WHERE id = $1
""", ticket_id)
return {"status": "ok"}
@router.get("/unread-count")
async def get_unread_count(request: Request):
"""Админ получает количество непрочитанных сообщений."""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
pool = await get_pool()
async with pool.acquire() as conn:
count = await conn.fetchval("""
SELECT COUNT(*) FROM support_messages
WHERE is_read = FALSE AND sender_id != $1
""", telegram_id)
return {"unread_count": count or 0}
@router.post("/translate")
async def translate_message(request: Request):
"""
Перевод сообщения через AI (MiniMax-M3).
Доступно и пользователю, и админу.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
text = body.get("text", "").strip()
target_lang = body.get("target_lang", "ru")
if not text:
raise HTTPException(400, "text is required")
if len(text) > 1000:
raise HTTPException(400, "Text too long (max 1000 characters)")
if target_lang not in ["pl", "ru", "uk", "en"]:
target_lang = "ru"
translated = await call_ai_translate(text, target_lang)
if not translated:
raise HTTPException(500, "Translation failed")
return {"translation": translated}
+1164
View File
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
"""
TTS Router — озвучка текста через Edge TTS.
Бесплатный, качественный синтез речи для польского языка.
Кэш хранится в файлах на диске.
"""
import logging
import io
import os
import hashlib
import edge_tts
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import Response
from auth import require_auth
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tts", tags=["tts"])
# Голоса Edge TTS для польского
VOICE_PL = "pl-PL-MarekNeural" # Мужской голос (основной)
VOICE_PL_FEMALE = "pl-PL-AgnieszkaNeural" # Женский голос
# Директория для кэша
CACHE_DIR = "/root/filwordspl/backend/tts_cache"
os.makedirs(CACHE_DIR, exist_ok=True)
def get_cache_path(voice_name: str, text: str) -> str:
"""Создаёт путь к файлу кэша на основе хеша текста и голоса"""
cache_key = hashlib.md5(f"{voice_name}:{text}".encode()).hexdigest()
return os.path.join(CACHE_DIR, f"{cache_key}.mp3")
@router.get("")
async def text_to_speech(request: Request, text: str = "", voice: str = "male"):
"""
Озвучивает текст через Edge TTS.
Параметры:
- text: текст для озвучки
- voice: male (Marek) или female (Agnieszka)
Возвращает: audio/mpeg
"""
await require_auth(request)
if not text or len(text) > 500:
raise HTTPException(400, "Text is required (max 500 characters)")
# Выбираем голос
if voice == "female":
voice_name = VOICE_PL_FEMALE
else:
voice_name = VOICE_PL
# Путь к файлу кэша
cache_path = get_cache_path(voice_name, text)
# Проверяем файловый кэш
if os.path.exists(cache_path):
logger.info(f"TTS: cache hit for text: {text[:50]}...")
with open(cache_path, 'rb') as f:
return Response(
content=f.read(),
media_type="audio/mpeg",
)
try:
logger.info(f"TTS: synthesizing text: {text[:50]}...")
# Синтезируем речь
communicate = edge_tts.Communicate(text, voice_name)
audio_data = io.BytesIO()
async for chunk in communicate.stream():
if chunk["type"] == "audio":
audio_data.write(chunk["data"])
audio_bytes = audio_data.getvalue()
if not audio_bytes:
raise HTTPException(500, "TTS generation failed")
# Сохраняем в файловый кэш
with open(cache_path, 'wb') as f:
f.write(audio_bytes)
logger.info(f"TTS: done, size={len(audio_bytes)} bytes, cached to {os.path.basename(cache_path)}")
return Response(
content=audio_bytes,
media_type="audio/mpeg",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"TTS error: {str(e)}")
raise HTTPException(500, f"TTS error: {str(e)}")
+141
View File
@@ -0,0 +1,141 @@
"""
Typing Router — раздел "Печать".
Пользователь печатает польские предложения, система проверяет.
Без AI — просто сравнение с правильным ответом из БД.
"""
import random
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
router = APIRouter(prefix="/typing", tags=["typing"])
def normalize_text(text: str) -> str:
"""Нормализует текст для сравнения: нижний регистр, убирает лишние пробелы."""
return ' '.join(text.strip().lower().split())
@router.get("/next")
async def get_next_typing_sentence(request: Request):
"""Выдаёт случайное непройденное предложение для выбранного уровня."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow("""
SELECT app_language, current_level FROM users WHERE telegram_id = $1
""", telegram_id)
if not user:
raise HTTPException(401, "User not registered")
lang = user["app_language"] or "pl"
current_level = user["current_level"] or "A1"
# Находим случайное непройденное предложение
row = await conn.fetchrow("""
SELECT s.id, s.sentence_pl, s.translation_ru, s.translation_uk, s.translation_en, s.level
FROM sentences s
WHERE s.level = $1
AND s.id NOT IN (
SELECT sentence_id FROM typing_progress WHERE user_id = $2
)
ORDER BY RANDOM()
LIMIT 1
""", current_level, telegram_id)
if not row:
return {"completed": True, "message": "Все предложения пройдены!"}
translation_field = f"translation_{lang}"
translation = row.get(translation_field) or row.get("translation_en") or row["sentence_pl"]
return {
"completed": False,
"sentence_id": row["id"],
"translation": translation,
"level": row["level"],
}
@router.post("/check")
async def check_typing_sentence(request: Request):
"""Проверяет напечатанное предложение."""
telegram_id, _ = await require_auth(request)
body = await request.json()
sentence_id = body.get("sentence_id")
user_text = body.get("text", "")
if not sentence_id or not user_text:
raise HTTPException(400, "sentence_id and text required")
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT sentence_pl FROM sentences WHERE id = $1
""", sentence_id)
if not row:
raise HTTPException(404, "Sentence not found")
correct_text = normalize_text(row["sentence_pl"])
user_normalized = normalize_text(user_text)
is_correct = correct_text == user_normalized
if is_correct:
# Сохраняем прогресс
await conn.execute("""
INSERT INTO typing_progress (user_id, sentence_id, completed_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id, sentence_id) DO NOTHING
""", telegram_id, sentence_id)
return {
"correct": is_correct,
"correct_answer": row["sentence_pl"],
}
@router.get("/hint/{sentence_id}")
async def get_typing_hint(request: Request, sentence_id: int):
"""Возвращает правильный ответ для подсказки."""
await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT sentence_pl FROM sentences WHERE id = $1
""", sentence_id)
if not row:
raise HTTPException(404, "Sentence not found")
return {"correct_answer": row["sentence_pl"]}
@router.get("/progress")
async def get_typing_progress(request: Request):
"""Статистика по печати."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow("""
SELECT current_level FROM users WHERE telegram_id = $1
""", telegram_id)
current_level = user["current_level"] if user else "A1"
total = await conn.fetchval("""
SELECT COUNT(*) FROM sentences WHERE level = $1
""", current_level) or 0
completed = await conn.fetchval("""
SELECT COUNT(*) FROM typing_progress tp
JOIN sentences s ON tp.sentence_id = s.id
WHERE tp.user_id = $1 AND s.level = $2
""", telegram_id, current_level) or 0
return {
"total": total,
"completed": completed,
"remaining": total - completed,
}
+106
View File
@@ -0,0 +1,106 @@
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
from config import SUPPORTED_LANGUAGES, ADMIN_IDS
router = APIRouter(prefix="/users", tags=["users"])
SUPPORTED_LEVELS = {"A1", "A2", "B1", "B2", "C1", "C2"}
@router.post("/register")
async def register_user(request: Request):
user_id, username = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем существует ли пользователь
existing = await conn.fetchrow(
"SELECT id, app_language, current_level FROM users WHERE telegram_id = $1", user_id
)
if existing:
return {
"status": "ok",
"app_language": existing["app_language"],
"current_level": existing["current_level"] or "A1",
"user_id": existing["id"],
"is_admin": user_id in ADMIN_IDS,
}
# Создаем нового пользователя
row = await conn.fetchrow(
"""INSERT INTO users (telegram_id, username, current_level)
VALUES ($1, $2, 'A1')
RETURNING id, app_language, current_level""",
user_id, username or f"id{user_id}",
)
return {
"status": "ok",
"app_language": row["app_language"],
"current_level": row["current_level"],
"user_id": row["id"],
"is_admin": user_id in ADMIN_IDS,
}
@router.get("/me/is-admin")
async def check_is_admin(request: Request):
"""Проверяет, является ли текущий пользователь администратором."""
user_id, _ = await require_auth(request)
return {"is_admin": user_id in ADMIN_IDS}
@router.post("/set-language")
async def set_language(request: Request):
user_id, _ = await require_auth(request)
body = await request.json()
lang = body.get("language", "pl")
if lang not in SUPPORTED_LANGUAGES:
raise HTTPException(400, "Unsupported language")
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE users SET app_language = $1 WHERE telegram_id = $2",
lang, user_id,
)
# 🔧 ИСПРАВЛЕНО: вызов достижений ВНЕ блока async with pool.acquire()
from routers.achievements import check_and_award_achievements
new_achievements = await check_and_award_achievements(user_id, "language_changed", 1)
return {
"status": "ok",
"app_language": lang,
"new_achievements": new_achievements
}
@router.post("/set-level")
async def set_level(request: Request):
"""Устанавливает текущий уровень пользователя (A1, A2, B1, B2, C1, C2)"""
user_id, _ = await require_auth(request)
body = await request.json()
level = body.get("level", "A1")
if level not in SUPPORTED_LEVELS:
raise HTTPException(400, "Unsupported level")
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем, что пользователь существует
user_exists = await conn.fetchval(
"SELECT 1 FROM users WHERE telegram_id = $1", user_id
)
if not user_exists:
raise HTTPException(401, "User not registered")
await conn.execute(
"UPDATE users SET current_level = $1 WHERE telegram_id = $2",
level, user_id,
)
return {
"status": "ok",
"level": level,
}
+282
View File
@@ -0,0 +1,282 @@
import random
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
router = APIRouter(prefix="/wordquiz", tags=["wordquiz"])
@router.get("/next")
async def get_next_quiz(request: Request):
"""Выдаёт случайное слово из базы выбранного уровня. Исключает недавно показанные."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
user = await conn.fetchrow(
"SELECT app_language, current_level FROM users WHERE telegram_id = $1", telegram_id
)
if not user:
raise HTTPException(401, "User not registered")
lang = user["app_language"] or "pl"
current_level = user["current_level"] or "A1"
progress = await conn.fetchrow(
"SELECT words_guessed, recent_words FROM word_quiz_progress WHERE user_id = $1",
telegram_id
)
total_guessed = progress["words_guessed"] if progress else 0
recent_words = list(progress["recent_words"]) if progress and progress["recent_words"] else []
async def pick_word(exclude, respect_exclude=True):
if respect_exclude and exclude:
placeholders = ",".join([f"${i+2}" for i in range(len(exclude))])
return await conn.fetchrow(f"""
SELECT word_pl, translation_ru, translation_uk, translation_en
FROM (
SELECT DISTINCT ON (word_pl) word_pl, translation_ru, translation_uk, translation_en
FROM level_words
WHERE level = $1 AND level_id IS NULL AND word_pl NOT IN ({placeholders})
) AS unique_words
ORDER BY RANDOM()
LIMIT 1
""", current_level, *exclude)
return await conn.fetchrow("""
SELECT word_pl, translation_ru, translation_uk, translation_en
FROM (
SELECT DISTINCT ON (word_pl) word_pl, translation_ru, translation_uk, translation_en
FROM level_words
WHERE level = $1 AND level_id IS NULL
) AS unique_words
ORDER BY RANDOM()
LIMIT 1
""", current_level)
word = await pick_word(recent_words, respect_exclude=True)
if not word:
word = await pick_word([], respect_exclude=False)
if not word:
raise HTTPException(404, "No words found")
correct_polish = word["word_pl"]
translation_field = f"translation_{lang}"
translation = word.get(translation_field) or word.get("translation_en") or correct_polish
# Ищем неправильные варианты
exclude_for_wrong = recent_words + [correct_polish]
placeholders = ",".join([f"${i+3}" for i in range(len(exclude_for_wrong))])
wrong_words = await conn.fetch(f"""
SELECT word_pl
FROM (
SELECT DISTINCT ON (word_pl) word_pl
FROM level_words
WHERE level = $1 AND level_id IS NULL AND word_pl != $2 AND word_pl NOT IN ({placeholders})
) AS unique_wrong
ORDER BY RANDOM()
LIMIT 5
""", current_level, correct_polish, *exclude_for_wrong)
if len(wrong_words) < 2:
wrong_words = await conn.fetch("""
SELECT word_pl
FROM (
SELECT DISTINCT ON (word_pl) word_pl
FROM level_words
WHERE level = $1 AND level_id IS NULL AND word_pl != $2
) AS unique_wrong
ORDER BY RANDOM()
LIMIT 5
""", current_level, correct_polish)
wrong_options = []
seen = {correct_polish.lower()}
for w in wrong_words:
wl = w["word_pl"].lower()
if wl not in seen:
wrong_options.append(w["word_pl"])
seen.add(wl)
if len(wrong_options) >= 2:
break
recent_words = recent_words + [correct_polish] + wrong_options
if len(recent_words) > 45:
recent_words = recent_words[-45:]
await conn.execute("""
INSERT INTO word_quiz_progress (user_id, words_guessed, total_score, recent_words)
VALUES ($1, 0, 0, $2::text[])
ON CONFLICT (user_id) DO UPDATE
SET recent_words = $2::text[]
""", telegram_id, recent_words)
options = wrong_options + [correct_polish]
random.shuffle(options)
if total_guessed <= 10:
difficulty = "easy"
elif total_guessed <= 30:
difficulty = "medium"
elif total_guessed <= 60:
difficulty = "hard"
else:
difficulty = "expert"
return {
"correct_polish": correct_polish,
"translation": translation,
"options": options,
"difficulty": difficulty,
}
@router.post("/check")
async def check_quiz_answer(request: Request):
"""Проверяет ответ, начисляет очки и проверяет достижения"""
telegram_id, _ = await require_auth(request)
body = await request.json()
correct_polish = body.get("correct_polish", "")
user_answer = body.get("answer", "")
mistakes = body.get("mistakes", 0)
completion_time = body.get("completion_time")
if not correct_polish or not user_answer:
raise HTTPException(400, "correct_polish and answer required")
# Защита от читерства: минимальное время 1 секунда
if completion_time is not None and completion_time < 1:
return {
"correct": False,
"correct_streak": 0,
"error": "Too fast. Please take your time."
}
is_correct = user_answer.strip().lower() == correct_polish.strip().lower()
pool = await get_pool()
async with pool.acquire() as conn:
current_streak = await conn.fetchval(
"SELECT correct_streak FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
if is_correct:
current_streak += 1
else:
current_streak = 0
if is_correct:
score = max(10, 50 - (mistakes * 20))
await conn.execute("""
INSERT INTO word_quiz_progress (user_id, words_guessed, total_score, correct_streak)
VALUES ($1, 1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET words_guessed = word_quiz_progress.words_guessed + 1,
total_score = word_quiz_progress.total_score + $2,
correct_streak = $3
""", telegram_id, score, current_streak)
from routers.achievements import check_and_award_achievements
new_achievements = []
total_guessed = await conn.fetchval(
"SELECT words_guessed FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
ach = await check_and_award_achievements(telegram_id, "quiz_guessed", total_guessed)
new_achievements.extend(ach)
if current_streak >= 10:
ach = await check_and_award_achievements(telegram_id, "quiz_perfect_streak", current_streak)
new_achievements.extend(ach)
if mistakes == 0:
ach = await check_and_award_achievements(telegram_id, "quiz_no_mistakes", 1)
new_achievements.extend(ach)
if completion_time is not None and completion_time > 0 and int(completion_time) <= 3:
ach = await check_and_award_achievements(telegram_id, "quiz_speed", 3)
new_achievements.extend(ach)
wordsearch_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM user_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
sentence_score = await conn.fetchval(
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
telegram_id
) or 0
wordquiz_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM word_quiz_progress WHERE user_id = $1",
telegram_id
) or 0
bubblewords_score = await conn.fetchval(
"SELECT COALESCE(total_score, 0) FROM bubble_words_progress WHERE user_id = $1",
telegram_id
) or 0
grand_total = wordsearch_score + sentence_score + wordquiz_score + bubblewords_score
ach = await check_and_award_achievements(telegram_id, "total_score", grand_total)
new_achievements.extend(ach)
return {
"correct": True,
"score": score,
"correct_streak": current_streak,
"new_achievements": new_achievements,
}
else:
# При 3 ошибках — начисляем утешительные 10 очков
if mistakes >= 3:
await conn.execute("""
INSERT INTO word_quiz_progress (user_id, words_guessed, total_score, correct_streak)
VALUES ($1, 0, 10, 0)
ON CONFLICT (user_id) DO UPDATE
SET total_score = word_quiz_progress.total_score + 10,
correct_streak = 0
""", telegram_id)
else:
await conn.execute("""
INSERT INTO word_quiz_progress (user_id, words_guessed, total_score, correct_streak)
VALUES ($1, 0, 0, $2)
ON CONFLICT (user_id) DO UPDATE
SET correct_streak = $2
""", telegram_id, current_streak)
return {
"correct": False,
"correct_streak": current_streak,
"consolation_score": 10 if mistakes >= 3 else 0,
}
@router.get("/progress")
async def get_quiz_progress(request: Request):
"""Статистика пользователя в квизе"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
progress = await conn.fetchrow(
"SELECT words_guessed, total_score, correct_streak FROM word_quiz_progress WHERE user_id = $1",
telegram_id
)
total_guessed = progress["words_guessed"] if progress else 0
if total_guessed <= 10:
difficulty = "easy"
elif total_guessed <= 30:
difficulty = "medium"
elif total_guessed <= 60:
difficulty = "hard"
else:
difficulty = "expert"
return {
"words_guessed": total_guessed,
"total_score": progress["total_score"] if progress else 0,
"correct_streak": progress["correct_streak"] if progress else 0,
"difficulty": difficulty
}