480 lines
19 KiB
Python
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"} |