""" 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"}