initial commit (backend)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
async def init_level_b1(conn):
|
||||
"""Уровень B1 — пока пусто"""
|
||||
pass
|
||||
@@ -0,0 +1,3 @@
|
||||
async def init_level_b2(conn):
|
||||
"""Уровень B2 — пока пусто"""
|
||||
pass
|
||||
@@ -0,0 +1,3 @@
|
||||
async def init_level_c1(conn):
|
||||
"""Уровень C1 — пока пусто"""
|
||||
pass
|
||||
@@ -0,0 +1,3 @@
|
||||
async def init_level_c2(conn):
|
||||
"""Уровень C2 — пока пусто"""
|
||||
pass
|
||||
@@ -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]
|
||||
@@ -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 ""
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)}")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user