initial commit (backend)
This commit is contained in:
@@ -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"}
|
||||
Reference in New Issue
Block a user