Files
2026-09-20 11:45:58 +00:00

480 lines
19 KiB
Python

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