initial commit (backend)
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
BOT_TOKEN=8886420359:AAEZgOsEBEk3pthIEJboUIb0MJZk0mGFX-A
|
||||
DB_PASSWORD=filwords123
|
||||
BOT_SECRET=filwords_secret_2024_random_string_here
|
||||
ADMIN_IDS=6694989403
|
||||
FRONTEND_ORIGIN=https://filwordspl.com
|
||||
DB_USER=filwords
|
||||
DB_NAME=filwords
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
TOGETHER_API_KEY=tgp_v1_7aSO98nDDhBtZS2sq1_Q1Q61AD7V1TrKKAiNbJlsusc
|
||||
SENTRY_DSN=https://a285ea156048a1adc7f0d00b3d49e90c@o4512063249121280.ingest.de.sentry.io/4512063274090576
|
||||
ENV=production
|
||||
# Версия приложения (используется в Sentry release)
|
||||
APP_VERSION=3.3.8щ
|
||||
|
||||
SENTRY_AUTH_TOKEN=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
|
||||
SENTRY_ORG_SLUG=drobysh
|
||||
SENTRY_PROJECT_SLUG=filwords-backend
|
||||
SENTRY_API_BASE=https://de.sentry.io/api/0
|
||||
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
tts_cache/
|
||||
archive/
|
||||
uploads/
|
||||
*.log
|
||||
.DS_Store
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Безопасное добавление уровней без удаления старых.
|
||||
Использует только words.csv.
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
import random
|
||||
import csv
|
||||
from config import DB_CONFIG
|
||||
|
||||
|
||||
DIRECTIONS = [(-1,0),(0,-1),(0,1),(1,0)]
|
||||
ALPHABET = "AĄBCĆDEĘFGHIJKLŁMNŃOÓPRSŚTUWYZŹŻ"
|
||||
|
||||
|
||||
def load_words_from_csv(filenames):
|
||||
words_by_theme = {}
|
||||
translations = {}
|
||||
for filename in filenames:
|
||||
with open(filename, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
theme = row['theme']
|
||||
word = row['polish'].lower()
|
||||
if theme not in words_by_theme:
|
||||
words_by_theme[theme] = []
|
||||
if word not in words_by_theme[theme]:
|
||||
words_by_theme[theme].append(word)
|
||||
translations[word] = {
|
||||
'en': row['english'],
|
||||
'ru': row['russian'],
|
||||
'uk': row['ukrainian']
|
||||
}
|
||||
return words_by_theme, translations
|
||||
|
||||
|
||||
def get_words_for_grid(words_by_theme, theme, num_words, grid_size):
|
||||
max_len = grid_size
|
||||
candidates = [w for w in words_by_theme[theme] if len(w) <= max_len]
|
||||
if len(candidates) < num_words:
|
||||
candidates = words_by_theme[theme]
|
||||
random.shuffle(candidates)
|
||||
return candidates[:num_words]
|
||||
|
||||
|
||||
def try_place_word(word, grid_size, occupied, max_attempts=500):
|
||||
word = word.upper()
|
||||
for _ in range(max_attempts):
|
||||
start = (random.randrange(grid_size), random.randrange(grid_size))
|
||||
if start in occupied:
|
||||
continue
|
||||
path = [start]
|
||||
visited = {start}
|
||||
ok = True
|
||||
for _ in range(len(word) - 1):
|
||||
r, c = path[-1]
|
||||
candidates = []
|
||||
for dr, dc in DIRECTIONS:
|
||||
nr, nc = r + dr, c + dc
|
||||
if 0 <= nr < grid_size and 0 <= nc < grid_size:
|
||||
if (nr, nc) not in occupied and (nr, nc) not in visited:
|
||||
candidates.append((nr, nc))
|
||||
if not candidates:
|
||||
ok = False
|
||||
break
|
||||
nxt = random.choice(candidates)
|
||||
path.append(nxt)
|
||||
visited.add(nxt)
|
||||
if ok and len(path) == len(word):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def fill_empty_cells(grid, grid_size, occupied, words_by_theme, all_words):
|
||||
empty_cells = set()
|
||||
for r in range(grid_size):
|
||||
for c in range(grid_size):
|
||||
if (r, c) not in occupied:
|
||||
empty_cells.add((r, c))
|
||||
|
||||
if not empty_cells:
|
||||
return [], {}
|
||||
|
||||
filler_words = []
|
||||
for theme in words_by_theme:
|
||||
for w in words_by_theme[theme]:
|
||||
if 2 <= len(w) <= 4 and w not in all_words:
|
||||
filler_words.append(w)
|
||||
|
||||
filler_words = sorted(list(set(filler_words)), key=len, reverse=True)
|
||||
random.shuffle(filler_words)
|
||||
|
||||
filled_paths = {}
|
||||
filled_words = []
|
||||
remaining_empty = empty_cells.copy()
|
||||
|
||||
for word in filler_words:
|
||||
if len(word) > len(remaining_empty):
|
||||
continue
|
||||
|
||||
for _ in range(500):
|
||||
if not remaining_empty:
|
||||
break
|
||||
start = random.choice(list(remaining_empty))
|
||||
|
||||
path = [start]
|
||||
visited = {start}
|
||||
ok = True
|
||||
|
||||
for _ in range(len(word) - 1):
|
||||
r, c = path[-1]
|
||||
candidates = []
|
||||
for dr, dc in DIRECTIONS:
|
||||
nr, nc = r + dr, c + dc
|
||||
if 0 <= nr < grid_size and 0 <= nc < grid_size:
|
||||
if (nr, nc) in remaining_empty and (nr, nc) not in visited:
|
||||
candidates.append((nr, nc))
|
||||
if not candidates:
|
||||
ok = False
|
||||
break
|
||||
nxt = random.choice(candidates)
|
||||
path.append(nxt)
|
||||
visited.add(nxt)
|
||||
|
||||
if ok and len(path) == len(word):
|
||||
for (r, c), letter in zip(path, word.upper()):
|
||||
grid[r][c] = letter
|
||||
if (r, c) in remaining_empty:
|
||||
remaining_empty.remove((r, c))
|
||||
|
||||
filled_paths[word] = [list(p) for p in path]
|
||||
filled_words.append(word)
|
||||
break
|
||||
|
||||
for r, c in remaining_empty:
|
||||
grid[r][c] = random.choice(ALPHABET)
|
||||
|
||||
return filled_words, filled_paths
|
||||
|
||||
|
||||
def generate_grid(words, grid_size, words_by_theme, max_restarts=200):
|
||||
words_sorted = sorted(words, key=len, reverse=True)
|
||||
for _ in range(max_restarts):
|
||||
grid = [['' for _ in range(grid_size)] for _ in range(grid_size)]
|
||||
occupied = set()
|
||||
word_paths = {}
|
||||
success = True
|
||||
|
||||
for word in words_sorted:
|
||||
path = try_place_word(word, grid_size, occupied)
|
||||
if path is None:
|
||||
success = False
|
||||
break
|
||||
for (r, c), letter in zip(path, word.upper()):
|
||||
grid[r][c] = letter
|
||||
occupied.update(path)
|
||||
word_paths[word] = [list(p) for p in path]
|
||||
|
||||
if not success:
|
||||
continue
|
||||
|
||||
all_words = set(words_sorted)
|
||||
filled_words, filled_paths = fill_empty_cells(
|
||||
grid, grid_size, occupied, words_by_theme, all_words
|
||||
)
|
||||
|
||||
word_paths.update(filled_paths)
|
||||
all_placed_words = words_sorted + filled_words
|
||||
|
||||
return grid, word_paths, all_placed_words
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
||||
async def save_level(conn, level_num, grid_size, theme, grid, word_paths, words, translations):
|
||||
level = await conn.fetchrow(
|
||||
"""INSERT INTO levels (level_number, grid_size, theme, grid_letters, word_paths)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id""",
|
||||
level_num, grid_size, theme, json.dumps(grid), json.dumps(word_paths)
|
||||
)
|
||||
level_id = level['id']
|
||||
for word in words:
|
||||
trans = translations.get(word, {})
|
||||
await conn.execute(
|
||||
"""INSERT INTO level_words (level_id, word_pl, translation_ru, translation_uk, translation_en)
|
||||
VALUES ($1, $2, $3, $4, $5)""",
|
||||
level_id, word,
|
||||
trans.get('ru', ''),
|
||||
trans.get('uk', ''),
|
||||
trans.get('en', '')
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# ============ НАСТРОЙКИ ============
|
||||
words_by_theme, translations = load_words_from_csv([
|
||||
'words.csv'
|
||||
])
|
||||
|
||||
NEW_LEVEL_CONFIG = [
|
||||
(25, 10, 9), # 25 уровней: 10x10, 9 слов
|
||||
(25, 10, 10), # 25 уровней: 10x10, 10 слов
|
||||
(25, 10, 11), # 25 уровней: 10x10, 11 слов
|
||||
(25, 10, 12), # 25 уровней: 10x10, 12 слов
|
||||
]
|
||||
# ==================================
|
||||
|
||||
conn = await asyncpg.connect(**DB_CONFIG)
|
||||
|
||||
current_max = await conn.fetchval("SELECT COALESCE(MAX(level_number), 0) FROM levels")
|
||||
print(f"Сейчас уровней в базе: {current_max}")
|
||||
print(f"Добавляем уровни {current_max + 1}–{current_max + 100}")
|
||||
|
||||
themes = list(words_by_theme.keys())
|
||||
random.shuffle(themes)
|
||||
|
||||
level_num = current_max
|
||||
theme_idx = 0
|
||||
total_generated = 0
|
||||
|
||||
for count, grid_size, num_words in NEW_LEVEL_CONFIG:
|
||||
for i in range(count):
|
||||
level_num += 1
|
||||
|
||||
for attempt in range(15):
|
||||
theme = themes[theme_idx % len(themes)]
|
||||
theme_idx += 1
|
||||
|
||||
words = get_words_for_grid(words_by_theme, theme, num_words, grid_size)
|
||||
if len(words) < num_words:
|
||||
continue
|
||||
|
||||
grid, word_paths, all_words = generate_grid(
|
||||
words, grid_size, words_by_theme
|
||||
)
|
||||
if grid is not None:
|
||||
await save_level(
|
||||
conn, level_num, grid_size, theme,
|
||||
grid, word_paths, all_words, translations
|
||||
)
|
||||
total_cells = grid_size * grid_size
|
||||
filled_cells = sum(len(w) for w in all_words)
|
||||
print(f"✅ Level {level_num}: {grid_size}x{grid_size}, "
|
||||
f"theme={theme}, words={len(all_words)} ({num_words} main), "
|
||||
f"fill={filled_cells}/{total_cells}")
|
||||
total_generated += 1
|
||||
break
|
||||
else:
|
||||
print(f"❌ FAILED: Level {level_num} after 15 attempts")
|
||||
level_num -= 1
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Готово! Добавлено {total_generated} новых уровней. Теперь всего уровней: {level_num}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Загрузка предложений по уровням (A1, A2, B1).
|
||||
Каждый уровень загружается из отдельного CSV файла.
|
||||
Нумерация предложений начинается с 1 для каждого уровня.
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import csv
|
||||
from config import DB_CONFIG
|
||||
|
||||
|
||||
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 main():
|
||||
conn = await asyncpg.connect(**DB_CONFIG)
|
||||
|
||||
level_files = [
|
||||
("A1", "sentences_a1.csv"),
|
||||
("A2", "sentences_a2.csv"),
|
||||
("B1", "sentences_b1.csv"),
|
||||
]
|
||||
|
||||
total_added = 0
|
||||
|
||||
for level, filename in level_files:
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Загружаю предложения уровня {level} из {filename}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
sentences = load_sentences_from_csv(filename)
|
||||
|
||||
# Удаляем старые предложения этого уровня
|
||||
await conn.execute("DELETE FROM sentences WHERE level = $1", level)
|
||||
|
||||
# Нумерация начинается с 1 для каждого уровня
|
||||
max_num = 0
|
||||
|
||||
added = 0
|
||||
for s in sentences:
|
||||
max_num += 1
|
||||
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)""",
|
||||
max_num, s["sentence_pl"],
|
||||
s["translation_ru"], s["translation_uk"], s["translation_en"],
|
||||
difficulty, level
|
||||
)
|
||||
added += 1
|
||||
print(f"✅ {level} #{max_num}: {s['sentence_pl'][:60]}...")
|
||||
|
||||
total_added += added
|
||||
print(f"Добавлено {added} предложений уровня {level}")
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Готово! Всего добавлено {total_added} предложений")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Архивация старых данных:
|
||||
1. Ответы старше 30 дней → user_learning_answers_archive (агрегаты)
|
||||
2. Данные архива старше 1 года → JSON файлы на диске
|
||||
3. Удаление из основных таблиц
|
||||
|
||||
Запуск: python3 archive_old_data.py
|
||||
Или через cron: 0 3 * * * cd /root/filwordspl/backend && source venv/bin/activate && python3 archive_old_data.py
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from config import DB_CONFIG
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Настройки
|
||||
HOT_DAYS = 30 # Сколько дней хранить в основной таблице
|
||||
ARCHIVE_DIR = Path("/root/filwordspl/backend/archive") # Директория для JSON файлов
|
||||
|
||||
|
||||
def serialize_value(value):
|
||||
"""Преобразует Decimal и datetime в JSON-совместимые типы."""
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, 'isoformat'):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (int, float, bool, str)):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
async def archive_learning_answers(conn):
|
||||
"""Переносит ответы старше 30 дней в архивную таблицу."""
|
||||
|
||||
# Переносим в архив
|
||||
moved = await conn.execute("""
|
||||
INSERT INTO user_learning_answers_archive (user_id, skill_tag, correct, wrong_because, created_at)
|
||||
SELECT user_id, skill_tag, correct, wrong_because, created_at::date
|
||||
FROM user_learning_answers
|
||||
WHERE created_at < NOW() - INTERVAL '30 days'
|
||||
""")
|
||||
|
||||
# Удаляем из основной таблицы
|
||||
deleted = await conn.execute("""
|
||||
DELETE FROM user_learning_answers
|
||||
WHERE created_at < NOW() - INTERVAL '30 days'
|
||||
""")
|
||||
|
||||
logger.info(f"Архивация ответов: перенесено={moved}, удалено={deleted}")
|
||||
return moved, deleted
|
||||
|
||||
|
||||
async def export_archive_to_json(conn):
|
||||
"""Экспортирует данные архива старше 1 года в JSON файлы."""
|
||||
|
||||
# Получаем данные старше 1 года
|
||||
old_records = await conn.fetch("""
|
||||
SELECT * FROM user_learning_answers_archive
|
||||
WHERE created_at < NOW() - INTERVAL '1 year'
|
||||
""")
|
||||
|
||||
if not old_records:
|
||||
logger.info("Нет данных старше 1 года для экспорта")
|
||||
return 0
|
||||
|
||||
# Группируем по году и пользователю
|
||||
grouped = {}
|
||||
for record in old_records:
|
||||
year = record["created_at"].year if record["created_at"] else datetime.now().year
|
||||
user_id = record["user_id"]
|
||||
|
||||
if year not in grouped:
|
||||
grouped[year] = {}
|
||||
if user_id not in grouped[year]:
|
||||
grouped[year][user_id] = []
|
||||
|
||||
grouped[year][user_id].append({
|
||||
"id": record["id"],
|
||||
"user_id": record["user_id"],
|
||||
"skill_tag": record["skill_tag"],
|
||||
"correct": record["correct"],
|
||||
"wrong_because": record["wrong_because"],
|
||||
"created_at": serialize_value(record["created_at"]),
|
||||
"archived_at": serialize_value(record["archived_at"]),
|
||||
})
|
||||
|
||||
# Сохраняем в JSON файлы
|
||||
total_saved = 0
|
||||
for year, users in grouped.items():
|
||||
year_dir = ARCHIVE_DIR / str(year)
|
||||
year_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for user_id, records in users.items():
|
||||
file_path = year_dir / f"user_{user_id}_answers.json"
|
||||
|
||||
# Если файл существует — дополняем
|
||||
existing_data = []
|
||||
if file_path.exists():
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
existing_data = json.load(f)
|
||||
except:
|
||||
existing_data = []
|
||||
|
||||
all_data = existing_data + records
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(all_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
total_saved += len(records)
|
||||
|
||||
# Удаляем экспортированные записи из архива
|
||||
if old_records:
|
||||
ids = [r["id"] for r in old_records]
|
||||
await conn.execute("""
|
||||
DELETE FROM user_learning_answers_archive WHERE id = ANY($1)
|
||||
""", ids)
|
||||
|
||||
logger.info(f"Экспортировано в JSON: {total_saved} записей")
|
||||
return total_saved
|
||||
|
||||
|
||||
async def archive_chat_history(conn):
|
||||
"""Архивирует старую историю чата (старше 6 месяцев)."""
|
||||
|
||||
# Экспортируем старую историю в JSON
|
||||
old_messages = await conn.fetch("""
|
||||
SELECT * FROM chat_history
|
||||
WHERE created_at < NOW() - INTERVAL '6 months'
|
||||
""")
|
||||
|
||||
if old_messages:
|
||||
# Группируем по году и пользователю
|
||||
grouped = {}
|
||||
for msg in old_messages:
|
||||
year = msg["created_at"].year if msg["created_at"] else datetime.now().year
|
||||
user_id = msg["user_id"]
|
||||
|
||||
if year not in grouped:
|
||||
grouped[year] = {}
|
||||
if user_id not in grouped[year]:
|
||||
grouped[year][user_id] = []
|
||||
|
||||
grouped[year][user_id].append({
|
||||
"id": msg["id"],
|
||||
"user_id": msg["user_id"],
|
||||
"message": msg["message"],
|
||||
"is_user": msg["is_user"],
|
||||
"created_at": serialize_value(msg["created_at"]),
|
||||
})
|
||||
|
||||
# Сохраняем в JSON
|
||||
total_saved = 0
|
||||
for year, users in grouped.items():
|
||||
year_dir = ARCHIVE_DIR / str(year)
|
||||
year_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for user_id, messages in users.items():
|
||||
file_path = year_dir / f"user_{user_id}_chat.json"
|
||||
|
||||
existing_data = []
|
||||
if file_path.exists():
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
existing_data = json.load(f)
|
||||
except:
|
||||
existing_data = []
|
||||
|
||||
all_data = existing_data + messages
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(all_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
total_saved += len(messages)
|
||||
|
||||
# Удаляем старые сообщения
|
||||
await conn.execute("""
|
||||
DELETE FROM chat_history
|
||||
WHERE created_at < NOW() - INTERVAL '6 months'
|
||||
""")
|
||||
|
||||
logger.info(f"Архивация чата: {total_saved} сообщений в JSON")
|
||||
return total_saved
|
||||
|
||||
logger.info("Нет старой истории чата для архивации")
|
||||
return 0
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=== Начало архивации ===")
|
||||
|
||||
conn = await asyncpg.connect(**DB_CONFIG)
|
||||
|
||||
try:
|
||||
# 1. Архивация ответов
|
||||
moved, deleted = await archive_learning_answers(conn)
|
||||
|
||||
# 2. Экспорт архива в JSON
|
||||
exported = await export_archive_to_json(conn)
|
||||
|
||||
# 3. Архивация чата
|
||||
chat_archived = await archive_chat_history(conn)
|
||||
|
||||
logger.info(f"=== Архивация завершена: ответы={moved}, JSON={exported}, чат={chat_archived} ===")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка архивации: {str(e)}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,108 @@
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import unquote
|
||||
from fastapi import Request, HTTPException
|
||||
from config import BOT_TOKEN, MAX_INITDATA_AGE
|
||||
|
||||
|
||||
async def verify_telegram_initdata(init_data: str):
|
||||
"""Проверяет подпись initData от Telegram с проверкой срока действия"""
|
||||
try:
|
||||
# Парсим initData
|
||||
data = {}
|
||||
for chunk in init_data.split('&'):
|
||||
if '=' in chunk:
|
||||
key, value = chunk.split('=', 1)
|
||||
data[key] = unquote(value)
|
||||
|
||||
# Извлекаем hash
|
||||
received_hash = data.pop('hash', '')
|
||||
|
||||
if not received_hash:
|
||||
raise HTTPException(401, "Hash not found in initData")
|
||||
|
||||
# Проверяем auth_date (не старше MAX_INITDATA_AGE из config)
|
||||
auth_date_str = data.get('auth_date', '0')
|
||||
try:
|
||||
auth_date = int(auth_date_str)
|
||||
current_time = int(time.time())
|
||||
|
||||
if current_time - auth_date > MAX_INITDATA_AGE:
|
||||
raise HTTPException(401, "initData expired")
|
||||
|
||||
if auth_date > current_time + 60:
|
||||
raise HTTPException(401, "initData from future")
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(401, "Invalid auth_date")
|
||||
|
||||
# Сортируем ключи и создаем строку для проверки
|
||||
check_arr = []
|
||||
for key in sorted(data.keys()):
|
||||
check_arr.append(f"{key}={data[key]}")
|
||||
check_string = '\n'.join(check_arr)
|
||||
|
||||
# Создаем секретный ключ
|
||||
secret_key = hmac.new(
|
||||
'WebAppData'.encode(),
|
||||
BOT_TOKEN.encode(),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
|
||||
# Вычисляем hash
|
||||
calculated_hash = hmac.new(
|
||||
secret_key,
|
||||
check_string.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if calculated_hash != received_hash:
|
||||
raise HTTPException(401, "Invalid initData signature")
|
||||
|
||||
# Извлекаем данные пользователя
|
||||
user_data_str = data.get('user', '{}')
|
||||
try:
|
||||
user_data = json.loads(user_data_str)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(401, "Invalid user data JSON")
|
||||
|
||||
user_id = user_data.get('id')
|
||||
username = user_data.get('username', '')
|
||||
first_name = user_data.get('first_name', '')
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(401, "User ID not found in initData")
|
||||
|
||||
return user_id, username
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(401, f"Auth error: {str(e)}")
|
||||
|
||||
|
||||
async def require_auth(request: Request):
|
||||
"""Извлекает и проверяет initData из запроса"""
|
||||
# Пробуем из query-параметров
|
||||
init_data = request.query_params.get('initData', '')
|
||||
|
||||
if not init_data:
|
||||
# Пробуем из заголовков
|
||||
init_data = request.headers.get('X-Telegram-InitData', '')
|
||||
|
||||
if not init_data:
|
||||
# Пробуем из тела для POST запросов
|
||||
# (некоторые клиенты могут отправлять initData в теле)
|
||||
pass
|
||||
|
||||
if not init_data:
|
||||
raise HTTPException(401, "initData required. Pass as query parameter 'initData' or header 'X-Telegram-InitData'")
|
||||
|
||||
# Декодируем URL-encoded initData если нужно
|
||||
if '%' in init_data:
|
||||
init_data = unquote(init_data)
|
||||
|
||||
user_id, username = await verify_telegram_initdata(init_data)
|
||||
return user_id, username
|
||||
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Проверка критических переменных
|
||||
for var in ["BOT_TOKEN", "DB_PASSWORD", "BOT_SECRET"]:
|
||||
if var not in os.environ:
|
||||
raise RuntimeError(f"Missing required environment variable: {var}")
|
||||
|
||||
BOT_TOKEN = os.environ["BOT_TOKEN"]
|
||||
BOT_SECRET = os.environ["BOT_SECRET"]
|
||||
|
||||
# Together AI для AI-помощника
|
||||
TOGETHER_API_KEY = os.getenv("TOGETHER_API_KEY", "")
|
||||
|
||||
DB_CONFIG = {
|
||||
"user": os.getenv("DB_USER", "filwords"),
|
||||
"password": os.environ["DB_PASSWORD"],
|
||||
"database": os.getenv("DB_NAME", "filwords"),
|
||||
"host": os.getenv("DB_HOST", "localhost"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
}
|
||||
|
||||
ADMIN_IDS = [int(id_str) for id_str in os.getenv("ADMIN_IDS", "6694989403").split(",") if id_str]
|
||||
FRONTEND_ORIGIN = os.getenv("FRONTEND_ORIGIN", "https://filwordspl.com")
|
||||
|
||||
# 🔧 ИСПРАВЛЕНО: единое значение — 24 часа.
|
||||
# Раньше в auth.py было локально 24 часа, а здесь 3600 (1 час) — это был конфликт.
|
||||
# Теперь значение берётся ТОЛЬКО отсюда.
|
||||
MAX_INITDATA_AGE = 24 * 60 * 60 # 24 часа в секундах
|
||||
|
||||
SUPPORTED_LANGUAGES = {"pl", "ru", "uk", "en"}
|
||||
|
||||
# ============ 🔧 НОВОЕ (18.09.2026): настройки для FeedModal ============
|
||||
# Папка для хранения картинок feed-постов.
|
||||
# Путь совпадает с backend/, файлы доступны как static mounts (см. main.py при необходимости).
|
||||
UPLOADS_DIR = os.getenv("UPLOADS_DIR", "/root/filwordspl/backend/uploads")
|
||||
|
||||
# Автосоздание папки — безопасно запускать повторно (exist_ok=True)
|
||||
os.makedirs(UPLOADS_DIR, exist_ok=True)
|
||||
|
||||
# Секции карусели (совпадают с SECTION_CONFIG в FeedModal.tsx)
|
||||
FEED_SECTIONS = {"news", "partners", "contacts", "apps"}
|
||||
|
||||
# Лимит картинок на один пост (совпадает с описанием — до 3)
|
||||
MAX_FEED_ATTACHMENTS = 3
|
||||
|
||||
# Лимит размера одной картинки — 5 МБ
|
||||
MAX_FEED_IMAGE_SIZE_MB = 5
|
||||
MAX_FEED_IMAGE_SIZE_BYTES = MAX_FEED_IMAGE_SIZE_MB * 1024 * 1024
|
||||
|
||||
# Разрешённые MIME-типы для картинок feed
|
||||
FEED_ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
}
|
||||
|
||||
# Языки, на которые переводим feed-посты (используется в admin_feed translate)
|
||||
FEED_TARGET_LANGUAGES = ["pl", "ru", "uk", "en"]
|
||||
+870
@@ -0,0 +1,870 @@
|
||||
import asyncpg
|
||||
import logging
|
||||
from config import DB_CONFIG
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
db_pool = None
|
||||
|
||||
async def get_pool():
|
||||
global db_pool
|
||||
if db_pool is None:
|
||||
db_pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10)
|
||||
return db_pool
|
||||
|
||||
async def init_db():
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# ============ users ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
telegram_id BIGINT UNIQUE NOT NULL,
|
||||
username TEXT,
|
||||
app_language TEXT DEFAULT 'pl',
|
||||
current_level TEXT DEFAULT 'A1',
|
||||
last_completed_level INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграция: добавляем current_level в users ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'current_level'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN current_level TEXT DEFAULT 'A1'
|
||||
""")
|
||||
logger.info("Added current_level column to users")
|
||||
|
||||
# ============ levels ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS levels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
level_number INTEGER NOT NULL,
|
||||
grid_size INTEGER NOT NULL,
|
||||
game_type TEXT DEFAULT 'wordsearch',
|
||||
level TEXT DEFAULT 'A1',
|
||||
grid_letters JSONB NOT NULL DEFAULT '[]',
|
||||
word_paths JSONB NOT NULL DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Убираем старый UNIQUE constraint с levels.level_number ============
|
||||
await conn.execute("""
|
||||
ALTER TABLE levels DROP CONSTRAINT IF EXISTS levels_level_number_key
|
||||
""")
|
||||
|
||||
# ============ Добавляем составной UNIQUE (level, level_number) ============
|
||||
await conn.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'levels_level_number_unique'
|
||||
) THEN
|
||||
ALTER TABLE levels ADD CONSTRAINT levels_level_number_unique UNIQUE (level, level_number);
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# ============ level_words ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS level_words (
|
||||
id SERIAL PRIMARY KEY,
|
||||
level_id INTEGER REFERENCES levels(id) ON DELETE CASCADE,
|
||||
word_pl TEXT NOT NULL,
|
||||
translation_ru TEXT DEFAULT '',
|
||||
translation_uk TEXT DEFAULT '',
|
||||
translation_en TEXT DEFAULT '',
|
||||
level TEXT DEFAULT 'A1'
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграция: добавляем level в level_words ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'level_words' AND column_name = 'level'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE level_words
|
||||
ADD COLUMN level TEXT DEFAULT 'A1'
|
||||
""")
|
||||
logger.info("Added level column to level_words")
|
||||
|
||||
# ============ user_progress ============
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'user_progress' AND column_name = 'user_id'
|
||||
""")
|
||||
|
||||
if existing and existing['data_type'] != 'bigint':
|
||||
logger.info("Migrating user_progress.user_id from INTEGER to BIGINT")
|
||||
await conn.execute("""
|
||||
ALTER TABLE user_progress
|
||||
DROP CONSTRAINT IF EXISTS user_progress_pkey CASCADE
|
||||
""")
|
||||
await conn.execute("""
|
||||
ALTER TABLE user_progress
|
||||
DROP CONSTRAINT IF EXISTS user_progress_user_id_fkey
|
||||
""")
|
||||
await conn.execute("""
|
||||
ALTER TABLE user_progress
|
||||
ALTER COLUMN user_id TYPE BIGINT
|
||||
""")
|
||||
await conn.execute("""
|
||||
ALTER TABLE user_progress
|
||||
ADD PRIMARY KEY (user_id, level_id)
|
||||
""")
|
||||
await conn.execute("""
|
||||
ALTER TABLE user_progress
|
||||
ADD FOREIGN KEY (user_id) REFERENCES users(telegram_id) ON DELETE CASCADE
|
||||
""")
|
||||
elif not existing:
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_progress (
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
level_id INTEGER REFERENCES levels(id) ON DELETE CASCADE,
|
||||
completed_at TIMESTAMP,
|
||||
hints_used INTEGER DEFAULT 0,
|
||||
score INTEGER DEFAULT 0,
|
||||
level TEXT DEFAULT 'A1',
|
||||
PRIMARY KEY (user_id, level_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграция: добавляем level в user_progress ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'user_progress' AND column_name = 'level'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE user_progress
|
||||
ADD COLUMN level TEXT DEFAULT 'A1'
|
||||
""")
|
||||
logger.info("Added level column to user_progress")
|
||||
|
||||
# ============ sentences ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sentences (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sentence_number INTEGER NOT NULL,
|
||||
sentence_pl TEXT NOT NULL,
|
||||
translation_ru TEXT DEFAULT '',
|
||||
translation_uk TEXT DEFAULT '',
|
||||
translation_en TEXT DEFAULT '',
|
||||
difficulty INTEGER DEFAULT 1,
|
||||
level TEXT DEFAULT 'A1'
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Убираем старый UNIQUE constraint с sentences.sentence_number ============
|
||||
await conn.execute("""
|
||||
ALTER TABLE sentences DROP CONSTRAINT IF EXISTS sentences_sentence_number_key
|
||||
""")
|
||||
|
||||
# ============ Добавляем составной UNIQUE (level, sentence_number) ============
|
||||
await conn.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'sentences_sentence_number_unique'
|
||||
) THEN
|
||||
ALTER TABLE sentences ADD CONSTRAINT sentences_sentence_number_unique UNIQUE (level, sentence_number);
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# ============ sentence_progress ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sentence_progress (
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
sentence_id INTEGER REFERENCES sentences(id) ON DELETE CASCADE,
|
||||
completed_at TIMESTAMP,
|
||||
score INTEGER DEFAULT 0,
|
||||
level TEXT DEFAULT 'A1',
|
||||
PRIMARY KEY (user_id, sentence_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграция: добавляем level в sentence_progress ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sentence_progress' AND column_name = 'level'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE sentence_progress
|
||||
ADD COLUMN level TEXT DEFAULT 'A1'
|
||||
""")
|
||||
logger.info("Added level column to sentence_progress")
|
||||
|
||||
# ============ typing_progress ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS typing_progress (
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
sentence_id INTEGER REFERENCES sentences(id) ON DELETE CASCADE,
|
||||
completed_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, sentence_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индекс для typing_progress ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_typing_progress_user ON typing_progress(user_id)")
|
||||
|
||||
# ============ chat_history ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
message TEXT NOT NULL,
|
||||
is_user BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индекс для chat_history ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_user ON chat_history(user_id, created_at DESC)")
|
||||
|
||||
# ============ chat_usage ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_usage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
usage_date DATE DEFAULT CURRENT_DATE,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
UNIQUE (user_id, usage_date)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индекс для chat_usage ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_usage_user ON chat_usage(user_id, usage_date DESC)")
|
||||
|
||||
# ============ learning_levels ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS learning_levels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(10) UNIQUE NOT NULL,
|
||||
name_pl TEXT NOT NULL,
|
||||
name_ru TEXT NOT NULL,
|
||||
name_uk TEXT NOT NULL,
|
||||
name_en TEXT NOT NULL,
|
||||
sort_order INT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ learning_topics ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS learning_topics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
topic_id VARCHAR(100) UNIQUE NOT NULL,
|
||||
level_code VARCHAR(10) NOT NULL,
|
||||
order_number INT NOT NULL,
|
||||
name_pl TEXT NOT NULL,
|
||||
name_ru TEXT NOT NULL,
|
||||
name_uk TEXT NOT NULL,
|
||||
name_en TEXT NOT NULL,
|
||||
description_pl TEXT,
|
||||
description_ru TEXT,
|
||||
description_uk TEXT,
|
||||
description_en TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
UNIQUE(level_code, order_number)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ learning_lessons ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS learning_lessons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
lesson_id VARCHAR(120) UNIQUE NOT NULL,
|
||||
topic_id VARCHAR(100) NOT NULL REFERENCES learning_topics(topic_id),
|
||||
order_number INT NOT NULL,
|
||||
skill_tag VARCHAR(100) NOT NULL,
|
||||
title_pl TEXT NOT NULL,
|
||||
title_ru TEXT NOT NULL,
|
||||
title_uk TEXT NOT NULL,
|
||||
title_en TEXT NOT NULL,
|
||||
objective_pl TEXT,
|
||||
objective_ru TEXT,
|
||||
objective_uk TEXT,
|
||||
objective_en TEXT,
|
||||
explanation_pl TEXT,
|
||||
explanation_ru TEXT,
|
||||
explanation_uk TEXT,
|
||||
explanation_en TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
UNIQUE(topic_id, order_number)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ lesson_examples ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lesson_examples (
|
||||
id SERIAL PRIMARY KEY,
|
||||
lesson_id VARCHAR(120) NOT NULL REFERENCES learning_lessons(lesson_id),
|
||||
order_number INT NOT NULL,
|
||||
text_pl TEXT NOT NULL,
|
||||
text_ru TEXT,
|
||||
text_uk TEXT,
|
||||
text_en TEXT,
|
||||
audio_url TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ learning_questions ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS learning_questions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
question_id VARCHAR(150) UNIQUE NOT NULL,
|
||||
lesson_id VARCHAR(120) REFERENCES learning_lessons(lesson_id),
|
||||
topic_id VARCHAR(100) NOT NULL REFERENCES learning_topics(topic_id),
|
||||
context_type VARCHAR(50) NOT NULL,
|
||||
question_type VARCHAR(50) NOT NULL,
|
||||
skill_tag VARCHAR(100) NOT NULL,
|
||||
difficulty INT NOT NULL CHECK (difficulty BETWEEN 1 AND 3),
|
||||
prompt_pl TEXT,
|
||||
prompt_ru TEXT,
|
||||
prompt_uk TEXT,
|
||||
prompt_en TEXT,
|
||||
correct_answer TEXT,
|
||||
hint_type VARCHAR(50),
|
||||
hint_value TEXT,
|
||||
is_test_question BOOLEAN DEFAULT FALSE,
|
||||
test_order INT,
|
||||
options_language TEXT DEFAULT 'pl'
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграция: добавляем options_language ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'learning_questions' AND column_name = 'options_language'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE learning_questions
|
||||
ADD COLUMN options_language TEXT DEFAULT 'pl'
|
||||
""")
|
||||
logger.info("Added options_language column to learning_questions")
|
||||
|
||||
# ============ question_options ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS question_options (
|
||||
id SERIAL PRIMARY KEY,
|
||||
question_id VARCHAR(150) NOT NULL REFERENCES learning_questions(question_id),
|
||||
option_key VARCHAR(20) NOT NULL,
|
||||
text_pl TEXT,
|
||||
text_ru TEXT,
|
||||
text_uk TEXT,
|
||||
text_en TEXT,
|
||||
is_correct BOOLEAN NOT NULL,
|
||||
wrong_because VARCHAR(100),
|
||||
UNIQUE(question_id, option_key)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ learning_sessions ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS learning_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
level_code VARCHAR(10) NOT NULL,
|
||||
topic_id VARCHAR(100) NOT NULL,
|
||||
session_type VARCHAR(30) NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMP,
|
||||
is_completed BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ user_learning_answers ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_learning_answers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
session_id BIGINT NOT NULL REFERENCES learning_sessions(id),
|
||||
level_code VARCHAR(10) NOT NULL,
|
||||
topic_id VARCHAR(100) NOT NULL,
|
||||
lesson_id VARCHAR(120),
|
||||
question_id VARCHAR(150) NOT NULL,
|
||||
selected_answer TEXT,
|
||||
correct BOOLEAN,
|
||||
skill_tag VARCHAR(100) NOT NULL,
|
||||
question_type VARCHAR(50) NOT NULL,
|
||||
context_type VARCHAR(50) NOT NULL,
|
||||
difficulty INT NOT NULL,
|
||||
time_taken_ms INT,
|
||||
attempt_number INT DEFAULT 1,
|
||||
hint_used BOOLEAN DEFAULT FALSE,
|
||||
wrong_because VARCHAR(100),
|
||||
is_final_answer BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ user_lesson_progress ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_lesson_progress (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
lesson_id VARCHAR(120) NOT NULL,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
attempts_count INT DEFAULT 0,
|
||||
correct_count INT DEFAULT 0,
|
||||
best_score NUMERIC(5,2),
|
||||
average_time_ms INT,
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
UNIQUE(user_id, lesson_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ user_topic_progress ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_topic_progress (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
topic_id VARCHAR(100) NOT NULL,
|
||||
lessons_total INT DEFAULT 0,
|
||||
lessons_completed INT DEFAULT 0,
|
||||
test_started BOOLEAN DEFAULT FALSE,
|
||||
test_completed BOOLEAN DEFAULT FALSE,
|
||||
test_score NUMERIC(5,2),
|
||||
topic_mastery NUMERIC(5,2),
|
||||
confidence NUMERIC(5,2),
|
||||
completed_at TIMESTAMP,
|
||||
UNIQUE(user_id, topic_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ user_skill_mastery (Analytics 2.0 + Этап 2) ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_skill_mastery (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
skill_tag VARCHAR(100) NOT NULL,
|
||||
attempts_count INT DEFAULT 0,
|
||||
correct_count INT DEFAULT 0,
|
||||
recognition_attempts INT DEFAULT 0,
|
||||
recognition_correct INT DEFAULT 0,
|
||||
recognition_score NUMERIC(5,2),
|
||||
production_attempts INT DEFAULT 0,
|
||||
production_correct INT DEFAULT 0,
|
||||
production_score NUMERIC(5,2),
|
||||
application_attempts INT DEFAULT 0,
|
||||
application_correct INT DEFAULT 0,
|
||||
application_score NUMERIC(5,2),
|
||||
retention_score NUMERIC(5,2) DEFAULT 0,
|
||||
hint_dependency NUMERIC(5,2) DEFAULT 0,
|
||||
average_time_ms INT DEFAULT 0,
|
||||
median_time_ms INT DEFAULT 0,
|
||||
median_correct_time_ms INT DEFAULT 0,
|
||||
median_wrong_time_ms INT DEFAULT 0,
|
||||
recent_accuracy NUMERIC(5,2) DEFAULT 0,
|
||||
last_3_accuracy NUMERIC(5,2),
|
||||
last_5_accuracy NUMERIC(5,2),
|
||||
last_10_accuracy NUMERIC(5,2),
|
||||
long_term_accuracy NUMERIC(5,2) DEFAULT 0,
|
||||
mastery_score NUMERIC(5,2) DEFAULT 0,
|
||||
previous_mastery_score NUMERIC(5,2) DEFAULT 0,
|
||||
mastery_delta NUMERIC(5,2) DEFAULT 0,
|
||||
trend TEXT DEFAULT 'insufficient_data',
|
||||
retention_status TEXT DEFAULT 'fresh',
|
||||
fluency_score NUMERIC(5,2) DEFAULT 0,
|
||||
passive_knowledge_score NUMERIC(5,2) DEFAULT 0,
|
||||
active_knowledge_score NUMERIC(5,2) DEFAULT 0,
|
||||
confidence NUMERIC(5,2) DEFAULT 0,
|
||||
error_count INT DEFAULT 0,
|
||||
repeated_error_count INT DEFAULT 0,
|
||||
days_since_last_attempt INT DEFAULT 0,
|
||||
last_attempt_at TIMESTAMP,
|
||||
UNIQUE(user_id, skill_tag)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграции для user_skill_mastery (Этап 2) ============
|
||||
stage2_columns = {
|
||||
"previous_mastery_score": "NUMERIC(5,2) DEFAULT 0",
|
||||
"mastery_delta": "NUMERIC(5,2) DEFAULT 0",
|
||||
"trend": "TEXT DEFAULT 'insufficient_data'",
|
||||
"retention_status": "TEXT DEFAULT 'fresh'",
|
||||
"fluency_score": "NUMERIC(5,2) DEFAULT 0",
|
||||
"passive_knowledge_score": "NUMERIC(5,2) DEFAULT 0",
|
||||
"active_knowledge_score": "NUMERIC(5,2) DEFAULT 0",
|
||||
}
|
||||
|
||||
for col_name, col_type in stage2_columns.items():
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'user_skill_mastery' AND column_name = $1
|
||||
""", col_name)
|
||||
if not col_exists:
|
||||
await conn.execute(f"""
|
||||
ALTER TABLE user_skill_mastery
|
||||
ADD COLUMN {col_name} {col_type}
|
||||
""")
|
||||
logger.info(f"Added {col_name} column to user_skill_mastery")
|
||||
|
||||
# ============ ai_user_context ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ai_user_context (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
current_level VARCHAR(10),
|
||||
completed_topics INT DEFAULT 0,
|
||||
level_topics_total INT DEFAULT 0,
|
||||
level_completion_percent NUMERIC(5,2) DEFAULT 0,
|
||||
completed_lessons INT DEFAULT 0,
|
||||
overall_mastery NUMERIC(5,2) DEFAULT 0,
|
||||
strong_skills JSONB DEFAULT '[]',
|
||||
weak_skills JSONB DEFAULT '[]',
|
||||
uncertain_skills JSONB DEFAULT '[]',
|
||||
recurring_errors JSONB DEFAULT '[]',
|
||||
recommended_topics JSONB DEFAULT '[]',
|
||||
recommended_lessons JSONB DEFAULT '[]',
|
||||
last_analysis_at TIMESTAMP,
|
||||
analytics_enabled BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграция: добавляем level_topics_total ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'ai_user_context' AND column_name = 'level_topics_total'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE ai_user_context
|
||||
ADD COLUMN level_topics_total INT DEFAULT 0
|
||||
""")
|
||||
logger.info("Added level_topics_total column to ai_user_context")
|
||||
|
||||
# ============ Авто-миграция: добавляем level_completion_percent ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'ai_user_context' AND column_name = 'level_completion_percent'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE ai_user_context
|
||||
ADD COLUMN level_completion_percent NUMERIC(5,2) DEFAULT 0
|
||||
""")
|
||||
logger.info("Added level_completion_percent column to ai_user_context")
|
||||
|
||||
# ============ achievements ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
category TEXT DEFAULT 'general',
|
||||
name_pl TEXT NOT NULL,
|
||||
name_ru TEXT NOT NULL,
|
||||
name_uk TEXT NOT NULL,
|
||||
name_en TEXT NOT NULL,
|
||||
description_pl TEXT DEFAULT '',
|
||||
description_ru TEXT DEFAULT '',
|
||||
description_uk TEXT DEFAULT '',
|
||||
description_en TEXT DEFAULT '',
|
||||
icon TEXT DEFAULT '🏆',
|
||||
requirement_type TEXT NOT NULL,
|
||||
requirement_value INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ user_achievements ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||
unlocked_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, achievement_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ daily_logins ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS daily_logins (
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
login_date DATE DEFAULT CURRENT_DATE,
|
||||
PRIMARY KEY (user_id, login_date)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ word_quiz_progress ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS word_quiz_progress (
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
words_guessed INTEGER DEFAULT 0,
|
||||
total_score INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграция: добавляем correct_streak ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'word_quiz_progress' AND column_name = 'correct_streak'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE word_quiz_progress
|
||||
ADD COLUMN correct_streak INTEGER DEFAULT 0
|
||||
""")
|
||||
logger.info("Added correct_streak column to word_quiz_progress")
|
||||
|
||||
# ============ Авто-миграция: добавляем recent_words ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'word_quiz_progress' AND column_name = 'recent_words'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE word_quiz_progress
|
||||
ADD COLUMN recent_words TEXT[] DEFAULT '{}'
|
||||
""")
|
||||
logger.info("Added recent_words column to word_quiz_progress")
|
||||
|
||||
# ============ Авто-миграция: добавляем level в word_quiz_progress ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'word_quiz_progress' AND column_name = 'level'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE word_quiz_progress
|
||||
ADD COLUMN level TEXT DEFAULT 'A1'
|
||||
""")
|
||||
logger.info("Added level column to word_quiz_progress")
|
||||
|
||||
# ============ bubble_words_progress ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS bubble_words_progress (
|
||||
user_id BIGINT REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||
levels_completed INTEGER DEFAULT 0,
|
||||
total_score INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Авто-миграция: добавляем level в bubble_words_progress ============
|
||||
col_exists = await conn.fetchval("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'bubble_words_progress' AND column_name = 'level'
|
||||
""")
|
||||
if not col_exists:
|
||||
await conn.execute("""
|
||||
ALTER TABLE bubble_words_progress
|
||||
ADD COLUMN level TEXT DEFAULT 'A1'
|
||||
""")
|
||||
logger.info("Added level column to bubble_words_progress")
|
||||
|
||||
# ============ wrong_because_explanations ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wrong_because_explanations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
wrong_because VARCHAR(100) UNIQUE NOT NULL,
|
||||
explanation_pl TEXT NOT NULL,
|
||||
explanation_ru TEXT NOT NULL,
|
||||
explanation_uk TEXT NOT NULL,
|
||||
explanation_en TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ teacher_reactions ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS teacher_reactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
reaction_type VARCHAR(50) NOT NULL,
|
||||
text_pl TEXT NOT NULL,
|
||||
text_ru TEXT NOT NULL,
|
||||
text_uk TEXT NOT NULL,
|
||||
text_en TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индекс для teacher_reactions ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_teacher_reactions_type ON teacher_reactions(reaction_type)")
|
||||
|
||||
# ============ teacher_chat_history ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS teacher_chat_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL UNIQUE,
|
||||
messages JSONB NOT NULL DEFAULT '[]',
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ teacher_ai_usage ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS teacher_ai_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
usage_date DATE DEFAULT CURRENT_DATE,
|
||||
command_count INTEGER DEFAULT 0,
|
||||
UNIQUE(user_id, usage_date)
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ daily_phrases — фраза дня ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS daily_phrases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
phrase_date DATE UNIQUE NOT NULL,
|
||||
phrase_pl TEXT NOT NULL,
|
||||
translations JSONB NOT NULL DEFAULT '{}',
|
||||
grammar_breakdowns JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индекс для daily_phrases ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_daily_phrases_date ON daily_phrases(phrase_date DESC)")
|
||||
|
||||
# ============ СИСТЕМА ПОДДЕРЖКИ ============
|
||||
# ============ support_tickets ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
status TEXT DEFAULT 'open',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индекс для support_tickets ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_support_tickets_user ON support_tickets(user_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status)")
|
||||
|
||||
# ============ support_messages ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS support_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ticket_id BIGINT NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
sender_id BIGINT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индекс для support_messages ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_support_messages_ticket ON support_messages(ticket_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_support_messages_sender ON support_messages(sender_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_support_messages_unread ON support_messages(ticket_id, is_read)")
|
||||
|
||||
# ============ 🔧 НОВОЕ (18.09.2026): feed_posts — посты для карусели ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feed_posts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
section TEXT NOT NULL,
|
||||
source_language TEXT NOT NULL DEFAULT 'ru',
|
||||
|
||||
title_pl TEXT,
|
||||
title_ru TEXT,
|
||||
title_uk TEXT,
|
||||
title_en TEXT,
|
||||
|
||||
body_pl TEXT,
|
||||
body_ru TEXT,
|
||||
body_uk TEXT,
|
||||
body_en TEXT,
|
||||
|
||||
link_url TEXT,
|
||||
telegram_url TEXT,
|
||||
|
||||
author_id BIGINT NOT NULL,
|
||||
is_pinned BOOLEAN DEFAULT FALSE,
|
||||
is_published BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
edited_at TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индексы для feed_posts ============
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_feed_posts_section
|
||||
ON feed_posts(section, created_at DESC)
|
||||
""")
|
||||
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_feed_posts_pinned
|
||||
ON feed_posts(section, is_pinned DESC, created_at DESC)
|
||||
""")
|
||||
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_feed_posts_published
|
||||
ON feed_posts(section, is_published)
|
||||
""")
|
||||
|
||||
# ============ 🔧 НОВОЕ (18.09.2026): feed_attachments — картинки к постам (до 3) ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feed_attachments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
post_id BIGINT NOT NULL REFERENCES feed_posts(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
file_type TEXT DEFAULT 'image',
|
||||
order_number INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индекс для feed_attachments ============
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_feed_attachments_post
|
||||
ON feed_attachments(post_id, order_number)
|
||||
""")
|
||||
|
||||
# ============ user_learning_answers_archive ============
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_learning_answers_archive (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
skill_tag VARCHAR(100) NOT NULL,
|
||||
correct BOOLEAN,
|
||||
wrong_because VARCHAR(100),
|
||||
created_at DATE,
|
||||
archived_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
# ============ Индексы для архива ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_archive_user_date ON user_learning_answers_archive(user_id, created_at)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_archive_skill_tag ON user_learning_answers_archive(skill_tag)")
|
||||
|
||||
# ============ Индексы ============
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_levels_number ON levels(level_number)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_levels_level ON levels(level)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_levels_level_number ON levels(level, level_number)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_progress_user ON user_progress(user_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_level_words_level_id ON level_words(level_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_level_words_level ON level_words(level)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_sentences_number ON sentences(sentence_number)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_sentences_level ON sentences(level)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_sentences_level_number ON sentences(level, sentence_number)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_sentence_progress_user ON sentence_progress(user_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_typing_progress_user ON typing_progress(user_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_progress_score ON user_progress(score DESC)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_sentence_progress_score ON sentence_progress(score DESC)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_daily_logins_user ON daily_logins(user_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_daily_logins_date ON daily_logins(login_date DESC)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_learning_topics_level ON learning_topics(level_code)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_learning_lessons_topic ON learning_lessons(topic_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_learning_questions_lesson ON learning_questions(lesson_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_learning_questions_topic ON learning_questions(topic_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_learning_answers_user ON user_learning_answers(user_id, created_at DESC)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_skill_mastery_user ON user_skill_mastery(user_id)")
|
||||
|
||||
logger.info("Database initialized")
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Собирает весь backend в один .md файл
|
||||
|
||||
PROJECT_DIR="/root/filwordspl/backend"
|
||||
OUTPUT="/root/FILWORDS_BACKEND_$(date +%Y%m%d_%H%M).md"
|
||||
|
||||
{
|
||||
echo "# 🐍 Filwords PL — Backend"
|
||||
echo ""
|
||||
echo "**Дата:** $(date)"
|
||||
echo "**Путь:** $PROJECT_DIR"
|
||||
echo ""
|
||||
|
||||
# Все .py файлы (кроме venv и __pycache__)
|
||||
for f in $(find "$PROJECT_DIR" -type f -name "*.py" -not -path "*/venv/*" -not -path "*/__pycache__/*" | sort); do
|
||||
REL_PATH="${f#$PROJECT_DIR/}"
|
||||
echo "### \`$REL_PATH\`"
|
||||
echo '```python'
|
||||
cat "$f"
|
||||
echo '```'
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Конфиги
|
||||
for f in "$PROJECT_DIR/requirements.txt" "$PROJECT_DIR/pytest.ini" "$PROJECT_DIR/.gitignore"; do
|
||||
if [ -f "$f" ]; then
|
||||
REL_PATH="${f#$PROJECT_DIR/}"
|
||||
echo "### \`$REL_PATH\`"
|
||||
echo '```'
|
||||
cat "$f"
|
||||
echo '```'
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
|
||||
# Тесты
|
||||
if [ -d "$PROJECT_DIR/tests" ]; then
|
||||
for f in $(find "$PROJECT_DIR/tests" -type f -name "*.py" | sort); do
|
||||
REL_PATH="${f#$PROJECT_DIR/}"
|
||||
echo "### \`$REL_PATH\`"
|
||||
echo '```python'
|
||||
cat "$f"
|
||||
echo '```'
|
||||
echo ""
|
||||
done
|
||||
fi
|
||||
|
||||
} > "$OUTPUT"
|
||||
|
||||
echo "🔍 Проверка секретов (должно быть 0):"
|
||||
grep -c -E "(sk-[a-zA-Z0-9]{20,}|postgresql://[^ ]*:[^ ]*@|BOT_TOKEN\s*=\s*[\"'][0-9]|password\s*=\s*[\"'][^\"']{4,})" "$OUTPUT" || echo "0"
|
||||
|
||||
echo "✅ Готово: $OUTPUT"
|
||||
echo "📊 Размер: $(du -h "$OUTPUT" | cut -f1)"
|
||||
echo "📏 Строк: $(wc -l < "$OUTPUT")"
|
||||
@@ -0,0 +1,43 @@
|
||||
with open('routers/learning.py', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Добавляем regex для удаления технических ID
|
||||
content = content.replace(
|
||||
''' data = response.json()
|
||||
ai_response = data["choices"][0]["message"]["content"].strip()
|
||||
ai_response = ai_response.replace("**", "").replace("*", "").replace("#", "")
|
||||
|
||||
return {"analytics_enabled": True, "response": ai_response}''',
|
||||
''' data = response.json()
|
||||
ai_response = data["choices"][0]["message"]["content"].strip()
|
||||
ai_response = ai_response.replace("**", "").replace("*", "").replace("#", "")
|
||||
|
||||
# Убираем технические ID в скобках: (self_intro), (family_masz_mam) и т.д.
|
||||
import re
|
||||
ai_response = re.sub(r'\\s*\\([a-z_]+_[a-z_]+\\)', '', ai_response)
|
||||
|
||||
return {"analytics_enabled": True, "response": ai_response}'''
|
||||
)
|
||||
|
||||
# Убираем из промпта упоминание процента завершения уровня (правило 12)
|
||||
content = content.replace(
|
||||
'''12. ВАЖНО: level_completion_percent показывает сколько тем пройдено из ВСЕГО уровня (A1 = 30+ тем). Если пройдено 5 тем из 30+ — это только начало (около 16%), а не "почти готово". НЕ говори что уровень почти завершён, если пройдено меньше 100%.
|
||||
''',
|
||||
'''12. ВАЖНО: НИКОГДА не упоминай процент завершения уровня, количество пройденных тем или сколько тем осталось. Не говори "ты прошёл X тем" или "осталось Y тем". Просто анализируй навыки.
|
||||
'''
|
||||
)
|
||||
|
||||
# Убираем из user_prompt пункт про прогресс
|
||||
content = content.replace(
|
||||
'''5. Твой общий прогресс и мотивация
|
||||
|
||||
В конце напиши: "Жду тебя в разделе Повторение!\\""""''',
|
||||
'''5. Твоя мотивация (без упоминания процентов и количества тем)
|
||||
|
||||
В конце напиши: "Жду тебя в разделе Повторение!\\""""'''
|
||||
)
|
||||
|
||||
with open('routers/learning.py', 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
print("learning.py fixed!")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Патч: исправляет колонку text_pl в question_options там, где при заполнении
|
||||
контента по ошибке продублировали русский перевод вместо настоящей польской
|
||||
фразы (вопросы типа "Co oznacza / Co znaczy ...?").
|
||||
|
||||
Затронуто 12 question_id по темам: greetings, family, food, home,
|
||||
numbers_time (3 шт.), colors_clothes, weather_seasons.
|
||||
|
||||
Запуск на сервере:
|
||||
python3 fix_polish_options.py
|
||||
Требует переменную DB_CONFIG из config.py (как остальные твои скрипты).
|
||||
|
||||
Скрипт идемпотентен: можно запускать повторно, ничего не сломает —
|
||||
он просто перезапишет text_pl тем же корректным значением.
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from config import DB_CONFIG
|
||||
|
||||
|
||||
# Каждая запись: (question_id, option_key, правильный польский текст)
|
||||
FIXES = [
|
||||
# ==================== Тема 1: Greetings ====================
|
||||
("a1_greetings_t09", "A", "Jak się nazywasz?"),
|
||||
("a1_greetings_t09", "B", "Jak się masz?"),
|
||||
("a1_greetings_t09", "C", "Gdzie mieszkasz?"),
|
||||
("a1_greetings_t09", "D", "Do widzenia."),
|
||||
|
||||
# ==================== Тема 3: Family ====================
|
||||
# Один и тот же набор опций встречается в уроке и в тесте
|
||||
("a1_family_l07_q1", "A", "Masz siostrę?"),
|
||||
("a1_family_l07_q1", "B", "Czy to jest twoja siostra?"),
|
||||
("a1_family_l07_q1", "C", "Jesteś siostrą?"),
|
||||
("a1_family_l07_q1", "D", "Gdzie jest twoja siostra?"),
|
||||
|
||||
("a1_family_t06", "A", "Masz siostrę?"),
|
||||
("a1_family_t06", "B", "Czy to jest twoja siostra?"),
|
||||
("a1_family_t06", "C", "Jesteś siostrą?"),
|
||||
("a1_family_t06", "D", "Gdzie jest twoja siostra?"),
|
||||
|
||||
# ==================== Тема 4: Food ====================
|
||||
("a1_food_l08_q1", "A", "Co jesz na śniadanie?"),
|
||||
("a1_food_l08_q1", "B", "Co pijesz rano?"),
|
||||
("a1_food_l08_q1", "C", "Kiedy jesz śniadanie?"),
|
||||
("a1_food_l08_q1", "D", "Czy lubisz śniadanie?"),
|
||||
|
||||
# ==================== Тема 5: Home ====================
|
||||
("a1_home_l09_q1", "A", "Ile masz pokoi?"),
|
||||
("a1_home_l09_q1", "B", "Gdzie jest twój pokój?"),
|
||||
("a1_home_l09_q1", "C", "Czy podoba ci się twój pokój?"),
|
||||
("a1_home_l09_q1", "D", "Jaki jest twój pokój?"),
|
||||
|
||||
# ==================== Тема 6: Numbers/Time ====================
|
||||
("a1_numbers_time_l05_q1", "A", "Która godzina?"),
|
||||
("a1_numbers_time_l05_q1", "B", "Jaki dziś dzień?"),
|
||||
("a1_numbers_time_l05_q1", "C", "Ile masz lat?"),
|
||||
("a1_numbers_time_l05_q1", "D", "Gdzie jesteś?"),
|
||||
|
||||
("a1_numbers_time_t04", "A", "Która godzina?"),
|
||||
("a1_numbers_time_t04", "B", "Jaki dziś dzień?"),
|
||||
("a1_numbers_time_t04", "C", "Ile masz lat?"),
|
||||
("a1_numbers_time_t04", "D", "Gdzie jesteś?"),
|
||||
|
||||
("a1_numbers_time_l06_q1", "A", "za piętnaście piąta"),
|
||||
("a1_numbers_time_l06_q1", "B", "kwadrans po czwartej"),
|
||||
("a1_numbers_time_l06_q1", "C", "wpół do piątej"),
|
||||
("a1_numbers_time_l06_q1", "D", "dokładnie piąta"),
|
||||
|
||||
("a1_numbers_time_l07_q1", "A", "O której godzinie?"),
|
||||
("a1_numbers_time_l07_q1", "B", "Która godzina?"),
|
||||
("a1_numbers_time_l07_q1", "C", "Jaki dziś dzień?"),
|
||||
("a1_numbers_time_l07_q1", "D", "Jak długo?"),
|
||||
|
||||
("a1_numbers_time_t05", "A", "O której godzinie?"),
|
||||
("a1_numbers_time_t05", "B", "Która godzina?"),
|
||||
("a1_numbers_time_t05", "C", "Jaki dziś dzień?"),
|
||||
("a1_numbers_time_t05", "D", "Jak długo?"),
|
||||
|
||||
# ==================== Тема 7: Colors/Clothes ====================
|
||||
("a1_colors_clothes_l09_q1", "A", "Jakiego koloru jest...?"),
|
||||
("a1_colors_clothes_l09_q1", "B", "Która godzina jest...?"),
|
||||
("a1_colors_clothes_l09_q1", "C", "Ile kosztuje...?"),
|
||||
("a1_colors_clothes_l09_q1", "D", "Gdzie jest...?"),
|
||||
|
||||
("a1_colors_clothes_t06", "A", "Jakiego koloru jest...?"),
|
||||
("a1_colors_clothes_t06", "B", "Która godzina jest...?"),
|
||||
("a1_colors_clothes_t06", "C", "Ile kosztuje...?"),
|
||||
("a1_colors_clothes_t06", "D", "Gdzie jest...?"),
|
||||
|
||||
# ==================== Тема 8: Weather/Seasons ====================
|
||||
("a1_weather_seasons_l09_q1", "A", "Jutro będzie padać deszcz."),
|
||||
("a1_weather_seasons_l09_q1", "B", "Wczoraj padał deszcz."),
|
||||
("a1_weather_seasons_l09_q1", "C", "Dziś pada deszcz."),
|
||||
("a1_weather_seasons_l09_q1", "D", "Jutro będzie zimno."),
|
||||
|
||||
("a1_weather_seasons_t06", "A", "Jutro będzie padać deszcz."),
|
||||
("a1_weather_seasons_t06", "B", "Wczoraj padał deszcz."),
|
||||
("a1_weather_seasons_t06", "C", "Dziś pada deszcz."),
|
||||
("a1_weather_seasons_t06", "D", "Jutro będzie zimno."),
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
pool = await asyncpg.create_pool(**DB_CONFIG)
|
||||
updated = 0
|
||||
not_found = []
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
for question_id, option_key, correct_pl in FIXES:
|
||||
result = await conn.execute("""
|
||||
UPDATE question_options
|
||||
SET text_pl = $3
|
||||
WHERE question_id = $1 AND option_key = $2
|
||||
""", question_id, option_key, correct_pl)
|
||||
|
||||
# asyncpg возвращает строку вида "UPDATE 1" или "UPDATE 0"
|
||||
affected = int(result.split()[-1])
|
||||
if affected == 0:
|
||||
not_found.append((question_id, option_key))
|
||||
else:
|
||||
updated += affected
|
||||
|
||||
await pool.close()
|
||||
|
||||
print(f"✅ Обновлено записей: {updated} из {len(FIXES)}")
|
||||
if not_found:
|
||||
print(f"⚠️ Не найдено в БД (проверь question_id/option_key вручную):")
|
||||
for qid, key in not_found:
|
||||
print(f" - {qid} / {key}")
|
||||
else:
|
||||
print("🎉 Все записи найдены и обновлены.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import re
|
||||
|
||||
with open('routers/review.py', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Полный SKILL_TAG_DESCRIPTION (как в learning.py)
|
||||
full_skill_tags = '''# Понятные названия навыков
|
||||
SKILL_TAG_DESCRIPTION = """
|
||||
Понятные названия навыков (используй ИХ в ответе, а не технические ID):
|
||||
- family_vocabulary → "слова по теме «Семья»"
|
||||
- family_possessive → "мой/моя (mój/moja)"
|
||||
- family_accusative → "винительный падеж (mam brata, mam siostrę)"
|
||||
- family_questions → "вопросы о семье (Masz brata?)"
|
||||
- family_description → "рассказ о семье"
|
||||
- family_babcia_dziadek → "бабушка и дедушка"
|
||||
- family_brat_siostra → "брат и сестра"
|
||||
- family_masz_mam → "у меня есть / у тебя есть"
|
||||
- family_duza_mala → "большая/маленькая семья"
|
||||
- family_mama_tata → "мама и папа"
|
||||
- family_maz_zona → "муж и жена"
|
||||
- family_dzieci → "дети"
|
||||
- family_wujek_ciocia → "дядя и тётя"
|
||||
- family_kto_to_jest → "вопрос «Кто это?»"
|
||||
- asking_name → "вопрос «Как тебя зовут?»"
|
||||
- answering_name → "ответ на вопрос об имени"
|
||||
- formal_address_pan_pani → "вежливое обращение Pan/Pani"
|
||||
- greeting_formal → "формальные приветствия"
|
||||
- greeting_informal → "неформальные приветствия"
|
||||
- asking_origin → "вопрос «Откуда ты?»"
|
||||
- answering_origin → "ответ о происхождении"
|
||||
- asking_residence → "вопрос «Где ты живёшь?»"
|
||||
- answering_residence → "ответ о месте жительства"
|
||||
- self_description → "рассказ о себе"
|
||||
- self_intro → "представление себя"
|
||||
- how_are_you → "вопрос «Как дела?»"
|
||||
- responding_how_are_you → "ответ на «Как дела?»"
|
||||
- farewell_formal → "формальные прощания"
|
||||
- farewell_informal → "неформальные прощания"
|
||||
- mieszkac_conjugation → "спряжение глагола mieszkać"
|
||||
- skad_vs_gdzie → "разница между Skąd и Gdzie"
|
||||
- country_vocabulary → "названия стран"
|
||||
- nationality_forms → "национальности"
|
||||
- city_vocabulary → "названия городов"
|
||||
- food_vocabulary → "слова по теме «Еда»"
|
||||
- food_fruits_vegetables → "фрукты и овощи"
|
||||
- food_drinks → "напитки"
|
||||
- food_meals → "приёмы пищи"
|
||||
- food_lubie → "глагол lubić (любить)"
|
||||
- home_vocabulary → "слова по теме «Дом»"
|
||||
- home_furniture → "мебель"
|
||||
- home_location → "предлоги места (w, na)"
|
||||
- home_ownership → "mam dom, mam mieszkanie"
|
||||
- home_description → "описание дома/квартиры"
|
||||
|
||||
НИКОГДА не пиши технические ID навыков в ответе. Всегда заменяй их на понятные названия из списка выше.
|
||||
"""'''
|
||||
|
||||
# Заменяем короткий SKILL_TAG_DESCRIPTION на полный
|
||||
content = re.sub(
|
||||
r'# Понятные названия навыков\nSKILL_TAG_DESCRIPTION = """.*?"""',
|
||||
full_skill_tags,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
# Добавляем attempts_count >= 2 в SQL
|
||||
content = content.replace(
|
||||
''' WHERE user_id = $1
|
||||
AND (
|
||||
mastery_score < 75
|
||||
OR retention_status IN ('needs_review', 'overdue')
|
||||
OR repeated_error_count > 0
|
||||
)''',
|
||||
''' WHERE user_id = $1
|
||||
AND attempts_count >= 2
|
||||
AND (
|
||||
mastery_score < 75
|
||||
OR retention_status IN ('needs_review', 'overdue')
|
||||
OR repeated_error_count > 0
|
||||
)'''
|
||||
)
|
||||
|
||||
# Добавляем regex для удаления технических ID
|
||||
content = content.replace(
|
||||
''' data = response.json()
|
||||
review_text = data["choices"][0]["message"]["content"].strip()
|
||||
review_text = review_text.replace("**", "").replace("*", "").replace("#", "")''',
|
||||
''' data = response.json()
|
||||
review_text = data["choices"][0]["message"]["content"].strip()
|
||||
review_text = review_text.replace("**", "").replace("*", "").replace("#", "")
|
||||
|
||||
# Убираем технические ID в скобках
|
||||
import re
|
||||
review_text = re.sub(r'\\s*\\([a-z_]+_[a-z_]+\\), '', review_text)'''
|
||||
)
|
||||
|
||||
# Добавляем правила в промпт
|
||||
content = content.replace(
|
||||
'''5. Каждый мини-урок: правило + 2 примера + 1 задание
|
||||
6. Не пиши технические ID навыков — используй понятные названия
|
||||
7. Будь дружелюбным и мотивирующим"""''',
|
||||
'''5. Каждый мини-урок: правило + 2 примера + 1 задание
|
||||
6. Не пиши технические ID навыков — используй понятные названия
|
||||
7. Будь дружелюбным и мотивирующим
|
||||
8. ВАЖНО: Никогда не давай готовый ответ на задание в том же сообщении. Задание должно оставаться открытым.
|
||||
9. ВАЖНО: В списках слов-подсказок используй только польские слова, без смешения языков.
|
||||
10. ВАЖНО: Если mastery >= 75 и навык включён из-за retention_status (needs_review/overdue) — формулируй как "быстрое освежение", а не "давай разберём с нуля"."""'''
|
||||
)
|
||||
|
||||
with open('routers/review.py', 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
print("review.py fixed!")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Полный сброс прогресса пользователя.
|
||||
Очищает ВСЕ данные по играм, урокам, чат-учителю, достижениям.
|
||||
Не трогает: users (остаётся), tts_cache (аудио).
|
||||
|
||||
Запуск: python3 full_reset.py
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from config import DB_CONFIG
|
||||
|
||||
USER_ID = 6694989403
|
||||
|
||||
|
||||
async def main():
|
||||
pool = await asyncpg.create_pool(**DB_CONFIG)
|
||||
|
||||
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:
|
||||
print(f"❌ Пользователь {USER_ID} не найден!")
|
||||
await pool.close()
|
||||
return
|
||||
|
||||
# Список таблиц для очистки (по user_id)
|
||||
user_tables = [
|
||||
"user_progress", # Филворды
|
||||
"sentence_progress", # Предложения
|
||||
"typing_progress", # Печать
|
||||
"word_quiz_progress", # Выбери слово
|
||||
"bubble_words_progress", # Пузыри слов
|
||||
"user_learning_answers", # Ответы на уроки
|
||||
"user_lesson_progress", # Прогресс уроков
|
||||
"user_topic_progress", # Прогресс тем
|
||||
"user_skill_mastery", # Мастерство
|
||||
"learning_sessions", # Сессии обучения
|
||||
"teacher_chat_history", # История чата учителя
|
||||
"teacher_ai_usage", # AI-команды
|
||||
"chat_history", # AI-чат
|
||||
"chat_usage", # Использование AI
|
||||
"user_achievements", # Достижения
|
||||
"daily_logins", # Входы
|
||||
"ai_user_context", # AI-контекст
|
||||
]
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
for table in user_tables:
|
||||
try:
|
||||
result = await conn.execute(f"""
|
||||
DELETE FROM {table} WHERE user_id = $1
|
||||
""", USER_ID)
|
||||
affected = int(result.split()[-1])
|
||||
total_deleted += affected
|
||||
print(f"🗑️ {table}: удалено {affected}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ {table}: {str(e)}")
|
||||
|
||||
# Сбрасываем last_completed_level
|
||||
await conn.execute("""
|
||||
UPDATE users SET last_completed_level = 0, current_level = 'A1'
|
||||
WHERE telegram_id = $1
|
||||
""", USER_ID)
|
||||
print("✅ last_completed_level = 0, current_level = A1")
|
||||
|
||||
# Очищаем архив ответов
|
||||
try:
|
||||
result = await conn.execute("""
|
||||
DELETE FROM user_learning_answers_archive WHERE user_id = $1
|
||||
""", USER_ID)
|
||||
print(f"🗑️ user_learning_answers_archive: удалено {result.split()[-1]}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ archive: {str(e)}")
|
||||
|
||||
await pool.close()
|
||||
print(f"\n🎉 Готово! Удалено {total_deleted} записей.")
|
||||
print("Можно начинать тестирование с чистого листа!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Автоматическая генерация фразы дня.
|
||||
Запускается через cron в 03:00 UTC (= 06:00 Минск).
|
||||
|
||||
Что делает:
|
||||
1. Проверяет, есть ли фраза на сегодня в daily_phrases
|
||||
2. Если нет — генерирует через AI (MiniMax-M3)
|
||||
3. Пишет в daily_phrases с phrase_date = CURRENT_DATE
|
||||
|
||||
Fallback: если AI упал — пишет запасную фразу.
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from config import DB_CONFIG, TOGETHER_API_KEY
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
|
||||
TEACHER_MODEL = "MiniMaxAI/MiniMax-M3"
|
||||
|
||||
# Список тем для фразы дня
|
||||
TOPICS_LIST = [
|
||||
"приветствие и знакомство",
|
||||
"семья и друзья",
|
||||
"еда и напитки",
|
||||
"покупки и магазины",
|
||||
"транспорт и путешествия",
|
||||
"работа и профессии",
|
||||
"погода и времена года",
|
||||
"дом и квартира",
|
||||
"хобби и свободное время",
|
||||
"здоровье и самочувствие",
|
||||
"город и местность",
|
||||
"ежедневная рутина",
|
||||
]
|
||||
|
||||
# Fallback фраза, если AI упал
|
||||
FALLBACK_PHRASE = "Lepiej późno niż wcale."
|
||||
FALLBACK_TRANSLATIONS = {
|
||||
"ru": "Лучше поздно, чем никогда.",
|
||||
"uk": "Краще пізно, ніж ніколи.",
|
||||
"en": "Better late than never."
|
||||
}
|
||||
FALLBACK_GRAMMAR = {
|
||||
"pl": "Lepiej - przysłówek w stopniu wyższym. Późno - przysłówek.",
|
||||
"ru": "Lepiej - наречие в сравнительной степени. Późno - наречие.",
|
||||
"uk": "Lepiej - прислівник у вищому ступені. Późno - прислівник.",
|
||||
"en": "Lepiej - adverb in comparative degree. Późno - adverb."
|
||||
}
|
||||
|
||||
|
||||
async def call_ai(system_prompt: str, user_prompt: str, max_tokens: int = 1000) -> str:
|
||||
"""Вызывает AI и возвращает ответ."""
|
||||
if not TOGETHER_API_KEY:
|
||||
logger.error("TOGETHER_API_KEY not configured")
|
||||
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": TEACHER_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"AI API error: {response.status_code}, body={response.text[:200]}")
|
||||
return ""
|
||||
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI call error: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
async def generate_phrase() -> tuple:
|
||||
"""
|
||||
Генерирует фразу дня через AI.
|
||||
Возвращает (phrase_pl, translations_dict, grammar_dict).
|
||||
"""
|
||||
selected_topic = random.choice(TOPICS_LIST)
|
||||
|
||||
system_prompt = f"""Ты — учитель польского языка. Создай фразу дня для изучения польского.
|
||||
Тема фразы: {selected_topic}.
|
||||
Фраза должна быть уровня A2-B1, полезной в повседневной жизни.
|
||||
|
||||
Верни ответ строго в таком формате (без markdown, просто текст):
|
||||
|
||||
PHRASE: [фраза на польском]
|
||||
RU: [перевод на русский]
|
||||
UK: [перевод на украинский]
|
||||
EN: [перевод на английский]
|
||||
PL_GRAMMAR: [грамматический разбор на польском, 1-2 предложения]
|
||||
RU_GRAMMAR: [грамматический разбор на русском, 1-2 предложения]
|
||||
UK_GRAMMAR: [грамматический разбор на украинском, 1-2 предложения]
|
||||
EN_GRAMMAR: [грамматический разбор на английском, 1-2 предложения]"""
|
||||
|
||||
user_prompt = f"Создай фразу дня на тему: {selected_topic}."
|
||||
|
||||
ai_response = await call_ai(system_prompt, user_prompt, max_tokens=1000)
|
||||
|
||||
if not ai_response:
|
||||
logger.warning("AI returned empty response, using fallback")
|
||||
return FALLBACK_PHRASE, FALLBACK_TRANSLATIONS, FALLBACK_GRAMMAR
|
||||
|
||||
# Парсим ответ
|
||||
phrase_pl = FALLBACK_PHRASE
|
||||
translations = FALLBACK_TRANSLATIONS.copy()
|
||||
grammar_breakdowns = FALLBACK_GRAMMAR.copy()
|
||||
|
||||
for line in ai_response.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('PHRASE:'):
|
||||
phrase_pl = line.replace('PHRASE:', '').strip()
|
||||
elif line.startswith('RU:'):
|
||||
translations["ru"] = line.replace('RU:', '').strip()
|
||||
elif line.startswith('UK:'):
|
||||
translations["uk"] = line.replace('UK:', '').strip()
|
||||
elif line.startswith('EN:'):
|
||||
translations["en"] = line.replace('EN:', '').strip()
|
||||
elif line.startswith('PL_GRAMMAR:'):
|
||||
grammar_breakdowns["pl"] = line.replace('PL_GRAMMAR:', '').strip()
|
||||
elif line.startswith('RU_GRAMMAR:'):
|
||||
grammar_breakdowns["ru"] = line.replace('RU_GRAMMAR:', '').strip()
|
||||
elif line.startswith('UK_GRAMMAR:'):
|
||||
grammar_breakdowns["uk"] = line.replace('UK_GRAMMAR:', '').strip()
|
||||
elif line.startswith('EN_GRAMMAR:'):
|
||||
grammar_breakdowns["en"] = line.replace('EN_GRAMMAR:', '').strip()
|
||||
|
||||
logger.info(f"Generated phrase for topic '{selected_topic}': {phrase_pl[:60]}...")
|
||||
return phrase_pl, translations, grammar_breakdowns
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=== Начало генерации фразы дня ===")
|
||||
|
||||
conn = await asyncpg.connect(**DB_CONFIG)
|
||||
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
# Проверяем, есть ли уже фраза на сегодня
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id FROM daily_phrases WHERE phrase_date = $1
|
||||
""", today)
|
||||
|
||||
if existing:
|
||||
logger.info(f"Phrase for {today} already exists, skipping")
|
||||
return
|
||||
|
||||
# Генерируем новую фразу
|
||||
logger.info(f"Generating phrase for {today}...")
|
||||
phrase_pl, translations, grammar_breakdowns = await generate_phrase()
|
||||
|
||||
# Пишем в БД
|
||||
await conn.execute("""
|
||||
INSERT INTO daily_phrases (phrase_date, phrase_pl, translations, grammar_breakdowns)
|
||||
VALUES ($1, $2, $3::jsonb, $4::jsonb)
|
||||
ON CONFLICT (phrase_date) DO UPDATE SET
|
||||
phrase_pl = EXCLUDED.phrase_pl,
|
||||
translations = EXCLUDED.translations,
|
||||
grammar_breakdowns = EXCLUDED.grammar_breakdowns
|
||||
""", today, phrase_pl,
|
||||
json.dumps(translations, ensure_ascii=False),
|
||||
json.dumps(grammar_breakdowns, ensure_ascii=False))
|
||||
|
||||
logger.info(f"✅ Phrase for {today} saved: {phrase_pl}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
logger.info("=== Готово ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,273 @@
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
import random
|
||||
import csv
|
||||
from config import DB_CONFIG
|
||||
|
||||
DIRECTIONS = [(-1,0),(0,-1),(0,1),(1,0)]
|
||||
ALPHABET = "AĄBCĆDEĘFGHIJKLŁMNŃOÓPRSŚTUWYZŹŻ"
|
||||
|
||||
def load_words_from_csv(filename):
|
||||
"""Загружает слова из CSV файла без тем. Возвращает список слов и словарь переводов."""
|
||||
words = []
|
||||
translations = {}
|
||||
with open(filename, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
word = row['polish'].strip().lower()
|
||||
if word and word not in words:
|
||||
words.append(word)
|
||||
translations[word] = {
|
||||
'en': row.get('english', ''),
|
||||
'ru': row.get('russian', ''),
|
||||
'uk': row.get('ukrainian', '')
|
||||
}
|
||||
return words, translations
|
||||
|
||||
def get_words_for_grid(all_words, num_words, grid_size):
|
||||
"""Выбирает случайные слова подходящей длины."""
|
||||
max_len = grid_size
|
||||
candidates = [w for w in all_words if len(w) <= max_len]
|
||||
if len(candidates) < num_words:
|
||||
candidates = all_words
|
||||
random.shuffle(candidates)
|
||||
return candidates[:num_words]
|
||||
|
||||
def try_place_word(word, grid_size, occupied, max_attempts=500):
|
||||
word = word.upper()
|
||||
for _ in range(max_attempts):
|
||||
start = (random.randrange(grid_size), random.randrange(grid_size))
|
||||
if start in occupied:
|
||||
continue
|
||||
path = [start]
|
||||
visited = {start}
|
||||
ok = True
|
||||
for _ in range(len(word) - 1):
|
||||
r, c = path[-1]
|
||||
candidates = []
|
||||
for dr, dc in DIRECTIONS:
|
||||
nr, nc = r + dr, c + dc
|
||||
if 0 <= nr < grid_size and 0 <= nc < grid_size:
|
||||
if (nr, nc) not in occupied and (nr, nc) not in visited:
|
||||
candidates.append((nr, nc))
|
||||
if not candidates:
|
||||
ok = False
|
||||
break
|
||||
nxt = random.choice(candidates)
|
||||
path.append(nxt)
|
||||
visited.add(nxt)
|
||||
if ok and len(path) == len(word):
|
||||
return path
|
||||
return None
|
||||
|
||||
def fill_empty_cells(grid, grid_size, occupied, all_words, used_words):
|
||||
"""Заполняет пустые клетки короткими словами из того же уровня."""
|
||||
empty_cells = set()
|
||||
for r in range(grid_size):
|
||||
for c in range(grid_size):
|
||||
if (r, c) not in occupied:
|
||||
empty_cells.add((r, c))
|
||||
|
||||
if not empty_cells:
|
||||
return [], {}
|
||||
|
||||
filler_words = [w for w in all_words if 2 <= len(w) <= 4 and w not in used_words]
|
||||
filler_words = sorted(list(set(filler_words)), key=len, reverse=True)
|
||||
random.shuffle(filler_words)
|
||||
|
||||
filled_paths = {}
|
||||
filled_words = []
|
||||
remaining_empty = empty_cells.copy()
|
||||
|
||||
for word in filler_words:
|
||||
if len(word) > len(remaining_empty):
|
||||
continue
|
||||
|
||||
for _ in range(500):
|
||||
if not remaining_empty:
|
||||
break
|
||||
start = random.choice(list(remaining_empty))
|
||||
|
||||
path = [start]
|
||||
visited = {start}
|
||||
ok = True
|
||||
|
||||
for _ in range(len(word) - 1):
|
||||
r, c = path[-1]
|
||||
candidates = []
|
||||
for dr, dc in DIRECTIONS:
|
||||
nr, nc = r + dr, c + dc
|
||||
if 0 <= nr < grid_size and 0 <= nc < grid_size:
|
||||
if (nr, nc) in remaining_empty and (nr, nc) not in visited:
|
||||
candidates.append((nr, nc))
|
||||
if not candidates:
|
||||
ok = False
|
||||
break
|
||||
nxt = random.choice(candidates)
|
||||
path.append(nxt)
|
||||
visited.add(nxt)
|
||||
|
||||
if ok and len(path) == len(word):
|
||||
for (r, c), letter in zip(path, word.upper()):
|
||||
grid[r][c] = letter
|
||||
if (r, c) in remaining_empty:
|
||||
remaining_empty.remove((r, c))
|
||||
|
||||
filled_paths[word] = [list(p) for p in path]
|
||||
filled_words.append(word)
|
||||
break
|
||||
|
||||
for r, c in remaining_empty:
|
||||
grid[r][c] = random.choice(ALPHABET)
|
||||
|
||||
return filled_words, filled_paths
|
||||
|
||||
def generate_grid(words, grid_size, all_words, used_words, max_restarts=200):
|
||||
words_sorted = sorted(words, key=len, reverse=True)
|
||||
for _ in range(max_restarts):
|
||||
grid = [['' for _ in range(grid_size)] for _ in range(grid_size)]
|
||||
occupied = set()
|
||||
word_paths = {}
|
||||
success = True
|
||||
|
||||
for word in words_sorted:
|
||||
path = try_place_word(word, grid_size, occupied)
|
||||
if path is None:
|
||||
success = False
|
||||
break
|
||||
for (r, c), letter in zip(path, word.upper()):
|
||||
grid[r][c] = letter
|
||||
occupied.update(path)
|
||||
word_paths[word] = [list(p) for p in path]
|
||||
|
||||
if not success:
|
||||
continue
|
||||
|
||||
all_used = set(words_sorted) | used_words
|
||||
filled_words, filled_paths = fill_empty_cells(
|
||||
grid, grid_size, occupied, all_words, all_used
|
||||
)
|
||||
|
||||
word_paths.update(filled_paths)
|
||||
all_placed_words = words_sorted + filled_words
|
||||
|
||||
return grid, word_paths, all_placed_words
|
||||
|
||||
return None, None, None
|
||||
|
||||
async def save_level(conn, level_num, grid_size, level_name, grid, word_paths, words, translations):
|
||||
level = await conn.fetchrow(
|
||||
"""INSERT INTO levels (level_number, grid_size, level, grid_letters, word_paths)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id""",
|
||||
level_num, grid_size, level_name, json.dumps(grid), json.dumps(word_paths)
|
||||
)
|
||||
level_id = level['id']
|
||||
for word in words:
|
||||
trans = translations.get(word, {})
|
||||
await conn.execute(
|
||||
"""INSERT INTO level_words (level_id, word_pl, translation_ru, translation_uk, translation_en, level)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||
level_id, word,
|
||||
trans.get('ru', ''),
|
||||
trans.get('uk', ''),
|
||||
trans.get('en', ''),
|
||||
level_name
|
||||
)
|
||||
|
||||
async def main():
|
||||
# Конфигурация уровней: 550 уровней на каждый уровень польского
|
||||
level_configs = [
|
||||
{
|
||||
"name": "A1",
|
||||
"file": "words_a1.csv",
|
||||
"levels": [
|
||||
(50, 4, 3), # 50 уровней: 4x4, 3 слова
|
||||
(100, 5, 4), # 100 уровней: 5x5, 4 слова
|
||||
(200, 6, 5), # 200 уровней: 6x6, 5 слов
|
||||
(200, 6, 6), # 200 уровней: 6x6, 6 слов
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "A2",
|
||||
"file": "words_a2.csv",
|
||||
"levels": [
|
||||
(100, 6, 5), # 100 уровней: 6x6, 5 слов
|
||||
(150, 7, 6), # 150 уровней: 7x7, 6 слов
|
||||
(150, 8, 7), # 150 уровней: 8x8, 7 слов
|
||||
(150, 8, 8), # 150 уровней: 8x8, 8 слов
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "B1",
|
||||
"file": "words_b1.csv",
|
||||
"levels": [
|
||||
(100, 8, 7), # 100 уровней: 8x8, 7 слов
|
||||
(150, 9, 8), # 150 уровней: 9x9, 8 слов
|
||||
(150, 10, 9), # 150 уровней: 10x10, 9 слов
|
||||
(150, 10, 10), # 150 уровней: 10x10, 10 слов
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
conn = await asyncpg.connect(**DB_CONFIG)
|
||||
|
||||
# Очищаем старые данные
|
||||
await conn.execute("DELETE FROM level_words")
|
||||
await conn.execute("DELETE FROM user_progress")
|
||||
await conn.execute("DELETE FROM sentence_progress")
|
||||
await conn.execute("DELETE FROM word_quiz_progress")
|
||||
await conn.execute("DELETE FROM bubble_words_progress")
|
||||
await conn.execute("DELETE FROM levels")
|
||||
await conn.execute("ALTER SEQUENCE levels_id_seq RESTART WITH 1")
|
||||
await conn.execute("UPDATE users SET last_completed_level = 0, current_level = 'A1'")
|
||||
|
||||
total_generated = 0
|
||||
|
||||
for level_config in level_configs:
|
||||
level_name = level_config["name"]
|
||||
filename = level_config["file"]
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Генерирую уровни для {level_name} из {filename}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
all_words, translations = load_words_from_csv(filename)
|
||||
print(f"Загружено слов: {len(all_words)}")
|
||||
|
||||
if len(all_words) < 10:
|
||||
print(f"❌ Слишком мало слов в {filename}")
|
||||
continue
|
||||
|
||||
used_words = set()
|
||||
level_num = 0
|
||||
|
||||
for count, grid_size, num_words in level_config["levels"]:
|
||||
for i in range(count):
|
||||
level_num += 1
|
||||
|
||||
for attempt in range(20):
|
||||
words = get_words_for_grid(all_words, num_words, grid_size)
|
||||
if len(words) < num_words:
|
||||
continue
|
||||
|
||||
grid, word_paths, all_placed_words = generate_grid(
|
||||
words, grid_size, all_words, used_words
|
||||
)
|
||||
if grid is not None:
|
||||
await save_level(
|
||||
conn, level_num, grid_size, level_name,
|
||||
grid, word_paths, all_placed_words, translations
|
||||
)
|
||||
used_words.update(all_placed_words)
|
||||
total_generated += 1
|
||||
print(f"✅ {level_name} Level {level_num}: {grid_size}x{grid_size}, {len(all_placed_words)} words")
|
||||
break
|
||||
else:
|
||||
print(f"❌ {level_name} Level {level_num}: FAILED after 20 attempts")
|
||||
|
||||
await conn.close()
|
||||
print(f"\n🎉 Готово! Сгенерировано уровней: {total_generated}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Заполняет поле options_language в learning_questions.
|
||||
Определяет, на каком языке показывать варианты ответов:
|
||||
- 'user' — для вопросов "Что означает...?" (варианты на языке пользователя)
|
||||
- 'pl' — для остальных (варианты на польском)
|
||||
|
||||
Запуск: python3 init_options_language.py
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from config import DB_CONFIG
|
||||
|
||||
|
||||
# Ключевые фразы, которые означают "вопрос на понимание смысла"
|
||||
USER_LANG_PATTERNS = [
|
||||
'co oznacza',
|
||||
'co znaczy',
|
||||
'что означает',
|
||||
'що означає',
|
||||
'what does',
|
||||
'what means',
|
||||
'переведи',
|
||||
'переклади',
|
||||
'przetłumacz',
|
||||
'translate',
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
pool = await asyncpg.create_pool(**DB_CONFIG)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Получаем все вопросы
|
||||
questions = await conn.fetch("""
|
||||
SELECT question_id, prompt_pl, prompt_ru, prompt_uk, prompt_en
|
||||
FROM learning_questions
|
||||
""")
|
||||
|
||||
user_lang_count = 0
|
||||
pl_lang_count = 0
|
||||
|
||||
for q in questions:
|
||||
# Собираем все промпты в один текст для проверки
|
||||
all_prompts = ' '.join([
|
||||
q['prompt_pl'] or '',
|
||||
q['prompt_ru'] or '',
|
||||
q['prompt_uk'] or '',
|
||||
q['prompt_en'] or '',
|
||||
]).lower()
|
||||
|
||||
# Определяем язык вариантов
|
||||
is_user_lang = any(pattern in all_prompts for pattern in USER_LANG_PATTERNS)
|
||||
options_language = 'user' if is_user_lang else 'pl'
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE learning_questions
|
||||
SET options_language = $2
|
||||
WHERE question_id = $1
|
||||
""", q['question_id'], options_language)
|
||||
|
||||
if is_user_lang:
|
||||
user_lang_count += 1
|
||||
else:
|
||||
pl_lang_count += 1
|
||||
|
||||
print(f"✅ options_language = 'user': {user_lang_count} вопросов")
|
||||
print(f"✅ options_language = 'pl': {pl_lang_count} вопросов")
|
||||
print(f" Всего: {user_lang_count + pl_lang_count}")
|
||||
|
||||
await pool.close()
|
||||
print("\n🎉 Готово!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Инициализация данных для чат-учителя:
|
||||
1. wrong_because_explanations — объяснения ошибок
|
||||
2. teacher_reactions — банк реакций (Марек)
|
||||
|
||||
Запуск: python3 init_teacher_data.py
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from config import DB_CONFIG
|
||||
|
||||
|
||||
# ============ Объяснения ошибок ============
|
||||
WRONG_BECAUSE_EXPLANATIONS = [
|
||||
{
|
||||
"wrong_because": "wrong_word",
|
||||
"pl": "To nie jest właściwe słowo w tym kontekście.",
|
||||
"ru": "Это не подходящее слово в данном контексте.",
|
||||
"uk": "Це не відповідне слово в даному контексті.",
|
||||
"en": "This is not the right word in this context."
|
||||
},
|
||||
{
|
||||
"wrong_because": "wrong_gender",
|
||||
"pl": "Uwaga na rodzaj gramatyczny! W języku polskim rzeczowniki mają rodzaje: męski, żeński i nijaki.",
|
||||
"ru": "Обрати внимание на грамматический род! В польском языке существительные бывают мужского, женского и среднего рода.",
|
||||
"uk": "Зверни увагу на граматичний рід! У польській мові іменники бувають чоловічого, жіночого та середнього роду.",
|
||||
"en": "Pay attention to grammatical gender! Polish nouns have masculine, feminine and neuter genders."
|
||||
},
|
||||
{
|
||||
"wrong_because": "wrong_case",
|
||||
"pl": "Sprawdź przypadek gramatyczny. W języku polskim jest 7 przypadków!",
|
||||
"ru": "Проверь грамматический падеж. В польском языке 7 падежей!",
|
||||
"uk": "Перевір граматичний відмінок. У польській мові 7 відмінків!",
|
||||
"en": "Check the grammatical case. Polish has 7 cases!"
|
||||
},
|
||||
{
|
||||
"wrong_because": "wrong_number",
|
||||
"pl": "Uwaga na liczbę! Pojedyncza czy mnoga?",
|
||||
"ru": "Обрати внимание на число! Единственное или множественное?",
|
||||
"uk": "Зверни увагу на число! Однина чи множина?",
|
||||
"en": "Pay attention to number! Singular or plural?"
|
||||
},
|
||||
{
|
||||
"wrong_because": "wrong_sentence_type",
|
||||
"pl": "To nie jest właściwy typ zdania (oznajmujące, pytające, rozkazujące).",
|
||||
"ru": "Это не правильный тип предложения (повествовательное, вопросительное, повелительное).",
|
||||
"uk": "Це не правильний тип речення (розповідне, питальне, наказове).",
|
||||
"en": "This is not the right sentence type (declarative, interrogative, imperative)."
|
||||
},
|
||||
{
|
||||
"wrong_because": "wrong_translation",
|
||||
"pl": "Tłumaczenie nie jest dokładne. Spróbuj jeszcze raz.",
|
||||
"ru": "Перевод не точный. Попробуй ещё раз.",
|
||||
"uk": "Переклад не точний. Спробуй ще раз.",
|
||||
"en": "The translation is not accurate. Try again."
|
||||
},
|
||||
{
|
||||
"wrong_because": "wrong_context",
|
||||
"pl": "To słowo nie pasuje do kontekstu zdania.",
|
||||
"ru": "Это слово не подходит к контексту предложения.",
|
||||
"uk": "Це слово не підходить до контексту речення.",
|
||||
"en": "This word doesn't fit the sentence context."
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ============ Реакции учителя Марека (нейтральные, без гендерной привязки) ============
|
||||
# 30 на "correct", 20 на "almost", 20 на "wrong"
|
||||
TEACHER_REACTIONS = [
|
||||
# ===== ПРАВИЛЬНЫЕ ОТВЕТЫ (30) =====
|
||||
{"type": "correct", "pl": "Świetnie! Dokładnie tak!", "ru": "Отлично! Именно так!", "uk": "Чудово! Саме так!", "en": "Great! Exactly right!"},
|
||||
{"type": "correct", "pl": "Brawo! Świetna robota!", "ru": "Браво! Отличная работа!", "uk": "Браво! Чудова робота!", "en": "Bravo! Great job!"},
|
||||
{"type": "correct", "pl": "Doskonale! Tak trzymaj!", "ru": "Превосходно! Так держать!", "uk": "Чудово! Так тримати!", "en": "Excellent! Keep it up!"},
|
||||
{"type": "correct", "pl": "Zgadza się! Widzę ogromny postęp!", "ru": "Верно! Вижу огромный прогресс!", "uk": "Вірно! Бачу величезний прогрес!", "en": "Correct! I see huge progress!"},
|
||||
{"type": "correct", "pl": "Perfekcyjnie! Zasługujesz na pochwałę!", "ru": "Идеально! Это заслуживает похвалы!", "uk": "Ідеально! Це заслуговує на похвалу!", "en": "Perfect! This deserves praise!"},
|
||||
{"type": "correct", "pl": "Tak jest! Bez żadnych błędów!", "ru": "Так точно! Без единой ошибки!", "uk": "Так точно! Без жодної помилки!", "en": "That's it! No mistakes at all!"},
|
||||
{"type": "correct", "pl": "Pięknie! Od razu widać postępy!", "ru": "Красиво! Сразу виден прогресс!", "uk": "Гарно! Одразу видно прогрес!", "en": "Beautiful! Progress is visible!"},
|
||||
{"type": "correct", "pl": "O tak! To jest poprawna odpowiedź!", "ru": "О да! Это правильный ответ!", "uk": "О так! Це правильна відповідь!", "en": "Oh yes! That's the correct answer!"},
|
||||
{"type": "correct", "pl": "Wspaniale! Z każdym razem lepiej!", "ru": "Замечательно! С каждым разом лучше!", "uk": "Чудово! З кожним разом краще!", "en": "Wonderful! Better every time!"},
|
||||
{"type": "correct", "pl": "Dokładnie o to chodziło!", "ru": "Именно это и имелось в виду!", "uk": "Саме це і малося на увазі!", "en": "That's exactly what I meant!"},
|
||||
{"type": "correct", "pl": "Super! To naprawdę imponujące!", "ru": "Супер! Это впечатляет!", "uk": "Супер! Це вражає!", "en": "Super! That's impressive!"},
|
||||
{"type": "correct", "pl": "Zgadza się! Polskie słowa nie są ci straszne!", "ru": "Верно! Польские слова не страшны!", "uk": "Вірно! Польські слова не страшні!", "en": "Right! Polish words don't scare you!"},
|
||||
{"type": "correct", "pl": "Idealnie! Jak rodowity Polak!", "ru": "Идеально! Как у носителя языка!", "uk": "Ідеально! Як у носія мови!", "en": "Perfect! Like a native speaker!"},
|
||||
{"type": "correct", "pl": "Masz rację! Pamięć działa świetnie!", "ru": "Верно! Память работает отлично!", "uk": "Вірно! Пам'ять працює чудово!", "en": "Right! Memory works great!"},
|
||||
{"type": "correct", "pl": "Świetna odpowiedź! Tak trzymaj dalej!", "ru": "Отличный ответ! Продолжай в том же духе!", "uk": "Чудова відповідь! Продовжуй у тому ж дусі!", "en": "Great answer! Keep it up!"},
|
||||
{"type": "correct", "pl": "Brawo! Widzę, że naprawdę się uczysz!", "ru": "Браво! Видно, что идёт настоящая учёба!", "uk": "Браво! Видно, що йде справжнє навчання!", "en": "Bravo! Real learning is happening!"},
|
||||
{"type": "correct", "pl": "Oczywiście! Dokładnie tak!", "ru": "Конечно! Именно так!", "uk": "Звісно! Саме так!", "en": "Of course! Exactly like that!"},
|
||||
{"type": "correct", "pl": "Znakomicie! Nie mogę się doczekać następnego pytania!", "ru": "Великолепно! Жду следующего вопроса!", "uk": "Чудово! Чекаю наступного питання!", "en": "Excellent! Can't wait for the next question!"},
|
||||
{"type": "correct", "pl": "Tak! To poprawna forma!", "ru": "Да! Это правильная форма!", "uk": "Так! Це правильна форма!", "en": "Yes! That's the correct form!"},
|
||||
{"type": "correct", "pl": "Prawidłowo! Jestem dumny z twoich postępów!", "ru": "Правильно! Я горжусь твоими успехами!", "uk": "Правильно! Я пишаюся твоїми успіхами!", "en": "Correct! I'm proud of your progress!"},
|
||||
{"type": "correct", "pl": "Dobrze! Nawet nie musiałeś się zastanawiać!", "ru": "Хорошо! Даже не пришлось задумываться!", "uk": "Добре! Навіть не довелося задумуватися!", "en": "Good! Didn't even hesitate!"},
|
||||
{"type": "correct", "pl": "Dokładnie! Język polski nie ma przed tobą tajemnic!", "ru": "Точно! У польского языка нет секретов!", "uk": "Точно! У польської мови немає секретів!", "en": "Exactly! Polish has no secrets!"},
|
||||
{"type": "correct", "pl": "Tak jest! Ciężka praca się opłaca!", "ru": "Так! Усердная работа окупается!", "uk": "Так! Наполеглива праця окупається!", "en": "That's it! Hard work pays off!"},
|
||||
{"type": "correct", "pl": "Doskonale! Zasługujesz na przerwę, ale najpierw kolejne pytanie!", "ru": "Отлично! Заслужил(а) перерыв, но сначала следующий вопрос!", "uk": "Чудово! Заслужив(ла) перерву, але спочатку наступне питання!", "en": "Excellent! You deserve a break, but first the next question!"},
|
||||
{"type": "correct", "pl": "Fantastycznie! Twoja wiedza rośnie!", "ru": "Фантастика! Знания растут!", "uk": "Фантастика! Знання ростуть!", "en": "Fantastic! Knowledge is growing!"},
|
||||
{"type": "correct", "pl": "Świetny wynik! Jestem pod wrażeniem!", "ru": "Отличный результат! Я впечатлён!", "uk": "Чудовий результат! Я вражений!", "en": "Great result! I'm impressed!"},
|
||||
{"type": "correct", "pl": "Brawo! Tak trzymaj!", "ru": "Браво! Так держать!", "uk": "Браво! Так тримати!", "en": "Bravo! Keep it up!"},
|
||||
{"type": "correct", "pl": "Zgadza się! Coraz lepiej ci idzie!", "ru": "Верно! Всё лучше и лучше получается!", "uk": "Вірно! Все краще і краще виходить!", "en": "Right! Getting better and better!"},
|
||||
{"type": "correct", "pl": "Dokładnie! Nic dodać, nic ująć!", "ru": "Точно! Ни добавить, ни убавить!", "uk": "Точно! Ні додати, ні відняти!", "en": "Exactly! Nothing to add, nothing to subtract!"},
|
||||
{"type": "correct", "pl": "Wspaniała robota! Jestem naprawdę dumny!", "ru": "Отличная работа! Я правда горжусь!", "uk": "Чудова робота! Я справді пишаюся!", "en": "Wonderful work! Truly proud!"},
|
||||
|
||||
# ===== ПОЧТИ ПРАВИЛЬНО (20) =====
|
||||
{"type": "almost", "pl": "Prawie! Jeszcze chwila...", "ru": "Почти! Ещё чуть-чуть...", "uk": "Майже! Ще трохи...", "en": "Almost! Just a little more..."},
|
||||
{"type": "almost", "pl": "Blisko! Spróbuj jeszcze raz.", "ru": "Близко! Попробуй ещё раз.", "uk": "Близько! Спробуй ще раз.", "en": "Close! Try again."},
|
||||
{"type": "almost", "pl": "Niewiele brakowało!", "ru": "Чуть-чуть не хватило!", "uk": "Трішки не вистачило!", "en": "So close!"},
|
||||
{"type": "almost", "pl": "Jesteś na dobrej drodze!", "ru": "Ты на правильном пути!", "uk": "Ти на правильному шляху!", "en": "You're on the right track!"},
|
||||
{"type": "almost", "pl": "Prawie dobrze! Jeszcze raz!", "ru": "Почти хорошо! Ещё раз!", "uk": "Майже добре! Ще раз!", "en": "Almost right! One more time!"},
|
||||
{"type": "almost", "pl": "Jeszcze troszeczkę...", "ru": "Ещё чуточку...", "uk": "Ще трішечки...", "en": "Just a tiny bit more..."},
|
||||
{"type": "almost", "pl": "Blisko, ale nie dokładnie. Pomyśl jeszcze chwilę.", "ru": "Близко, но не точно. Подумай ещё немного.", "uk": "Близько, але не точно. Подумай ще трохи.", "en": "Close, but not quite. Think a bit more."},
|
||||
{"type": "almost", "pl": "Dobra intuicja! Ale spróbuj poprawić.", "ru": "Хорошая интуиция! Но попробуй исправить.", "uk": "Гарна інтуїція! Але спробуй виправити.", "en": "Good intuition! But try to correct it."},
|
||||
{"type": "almost", "pl": "To nie jest złe, ale nie do końca to, o co pytam.", "ru": "Это неплохо, но не совсем то, о чём я спрашиваю.", "uk": "Це непогано, але не зовсім те, про що я питаю.", "en": "Not bad, but not quite what I'm asking."},
|
||||
{"type": "almost", "pl": "Jesteś bardzo blisko! Mała poprawka i będzie idealnie.", "ru": "Очень близко! Маленькая поправка и будет идеально.", "uk": "Дуже близько! Маленьке виправлення і буде ідеально.", "en": "Very close! Small correction and it'll be perfect."},
|
||||
{"type": "almost", "pl": "Prawie! Sprawdź jeszcze raz pisownię.", "ru": "Почти! Проверь ещё раз написание.", "uk": "Майже! Перевір ще раз написання.", "en": "Almost! Check the spelling again."},
|
||||
{"type": "almost", "pl": "Niewielka pomyłka! Skup się na szczegółach.", "ru": "Небольшая ошибка! Сосредоточься на деталях.", "uk": "Невелика помилка! Зосередься на деталях.", "en": "Small mistake! Focus on the details."},
|
||||
{"type": "almost", "pl": "Blisko! Jeden mały krok do sukcesu.", "ru": "Близко! Один маленький шаг до успеха.", "uk": "Близько! Один маленький крок до успіху.", "en": "Close! One small step to success."},
|
||||
{"type": "almost", "pl": "Jesteś blisko! Ale nie tym razem.", "ru": "Близко! Но не в этот раз.", "uk": "Близько! Але не цього разу.", "en": "Close! But not this time."},
|
||||
{"type": "almost", "pl": "Prawie! Nie zatrzymuj się teraz.", "ru": "Почти! Не останавливайся сейчас.", "uk": "Майже! Не зупиняйся зараз.", "en": "Almost! Don't stop now."},
|
||||
{"type": "almost", "pl": "Jeszcze jeden krok! Spróbuj ponownie.", "ru": "Ещё один шаг! Попробуй снова.", "uk": "Ще один крок! Спробуй знову.", "en": "One more step! Try again."},
|
||||
{"type": "almost", "pl": "Niewiele brakuje do perfekcji!", "ru": "Немного не хватает до совершенства!", "uk": "Трішки не вистачає до досконалості!", "en": "Just a bit short of perfection!"},
|
||||
{"type": "almost", "pl": "Bardzo blisko! Jeszcze jedna próba.", "ru": "Очень близко! Ещё одна попытка.", "uk": "Дуже близько! Ще одна спроба.", "en": "Very close! One more attempt."},
|
||||
{"type": "almost", "pl": "Prawie udało się! Nie poddawaj się.", "ru": "Почти получилось! Не сдавайся.", "uk": "Майже вийшло! Не здавайся.", "en": "Almost got it! Don't give up."},
|
||||
{"type": "almost", "pl": "Mała poprawka i będzie idealnie!", "ru": "Маленькая поправка и будет идеально!", "uk": "Маленьке виправлення і буде ідеально!", "en": "Small correction and it'll be perfect!"},
|
||||
|
||||
# ===== НЕПРАВИЛЬНЫЕ ОТВЕТЫ (20) =====
|
||||
{"type": "wrong", "pl": "Nie tym razem. Nie poddawaj się!", "ru": "Не в этот раз. Не сдавайся!", "uk": "Не цього разу. Не здавайся!", "en": "Not this time. Don't give up!"},
|
||||
{"type": "wrong", "pl": "To nie to. Spróbuj jeszcze raz!", "ru": "Это не то. Попробуй ещё раз!", "uk": "Це не те. Спробуй ще раз!", "en": "That's not it. Try again!"},
|
||||
{"type": "wrong", "pl": "Zdarza się! Ważne, że próbujesz.", "ru": "Бывает! Главное, что ты пытаешься.", "uk": "Буває! Головне, що ти намагаєшся.", "en": "It happens! What matters is that you try."},
|
||||
{"type": "wrong", "pl": "Błąd to część nauki!", "ru": "Ошибка — часть обучения!", "uk": "Помилка — частина навчання!", "en": "Mistakes are part of learning!"},
|
||||
{"type": "wrong", "pl": "Nie szkodzi! Uczymy się na błędach.", "ru": "Ничего страшного! Мы учимся на ошибках.", "uk": "Нічого страшного! Ми вчимося на помилках.", "en": "No worries! We learn from mistakes."},
|
||||
{"type": "wrong", "pl": "Nie martw się! Każdy mistrz kiedyś zaczynał od zera.", "ru": "Не переживай! Каждый мастер когда-то начинал с нуля.", "uk": "Не хвилюйся! Кожен майстер колись починав з нуля.", "en": "Don't worry! Every master started from zero."},
|
||||
{"type": "wrong", "pl": "Pomyłka to dobry znak — znaczy, że próbujesz!", "ru": "Ошибка — хороший знак, значит есть попытка!", "uk": "Помилка — гарний знак, значить є спроба!", "en": "A mistake is a good sign — it means you're trying!"},
|
||||
{"type": "wrong", "pl": "Nie udało się, ale nie przestawaj!", "ru": "Не получилось, но не останавливайся!", "uk": "Не вийшло, але не зупиняйся!", "en": "Didn't work, but don't stop!"},
|
||||
{"type": "wrong", "pl": "Sprawdź dokładnie i spróbuj ponownie.", "ru": "Проверь внимательно и попробуй снова.", "uk": "Перевір уважно і спробуй знову.", "en": "Check carefully and try again."},
|
||||
{"type": "wrong", "pl": "Nie tym razem, ale doceniam twoje zaangażowanie!", "ru": "Не в этот раз, но я ценю усердие!", "uk": "Не цього разу, але я ціную старанність!", "en": "Not this time, but I appreciate the effort!"},
|
||||
{"type": "wrong", "pl": "Spokojnie! Nawet najlepsi popełniają błędy.", "ru": "Спокойно! Даже лучшие ошибаются.", "uk": "Спокійно! Навіть найкращі помиляються.", "en": "Relax! Even the best make mistakes."},
|
||||
{"type": "wrong", "pl": "Nie poddawaj się! Jesteś bliżej niż myślisz.", "ru": "Не сдавайся! Ты ближе, чем думаешь.", "uk": "Не здавайся! Ти ближче, ніж думаєш.", "en": "Don't give up! You're closer than you think."},
|
||||
{"type": "wrong", "pl": "Spróbuj inaczej! Może pomoże inne podejście.", "ru": "Попробуй иначе! Может, поможет другой подход.", "uk": "Спробуй інакше! Може, допоможе інший підхід.", "en": "Try differently! Maybe another approach will help."},
|
||||
{"type": "wrong", "pl": "Nie zniechęcaj się! To normalny etap nauki.", "ru": "Не расстраивайся! Это нормальный этап обучения.", "uk": "Не засмучуйся! Це нормальний етап навчання.", "en": "Don't be discouraged! It's a normal stage of learning."},
|
||||
{"type": "wrong", "pl": "Każdy błąd to lekcja na przyszłość!", "ru": "Каждая ошибка — урок на будущее!", "uk": "Кожна помилка — урок на майбутнє!", "en": "Every mistake is a lesson for the future!"},
|
||||
{"type": "wrong", "pl": "Nie martw się! Mózg najlepiej uczy się na błędach.", "ru": "Не переживай! Мозг лучше всего учится на ошибках.", "uk": "Не хвилюйся! Мозок найкраще вчиться на помилках.", "en": "Don't worry! The brain learns best from mistakes."},
|
||||
{"type": "wrong", "pl": "To normalne! Nawet ja czasem się mylę.", "ru": "Это нормально! Даже я иногда ошибаюсь.", "uk": "Це нормально! Навіть я іноді помиляюся.", "en": "That's normal! Even I make mistakes sometimes."},
|
||||
{"type": "wrong", "pl": "Ważne, że się uczysz! Błędy to część drogi.", "ru": "Важно, что ты учишься! Ошибки — часть пути.", "uk": "Важливо, що ти вчишся! Помилки — частина шляху.", "en": "What matters is that you're learning! Mistakes are part of the journey."},
|
||||
{"type": "wrong", "pl": "Nie przejmuj się! Następnym razem się uda.", "ru": "Не переживай! В следующий раз получится.", "uk": "Не хвилюйся! Наступного разу вийде.", "en": "Don't worry! Next time it'll work."},
|
||||
{"type": "wrong", "pl": "Uczymy się razem! To dopiero początek.", "ru": "Мы учимся вместе! Это только начало.", "uk": "Ми вчимося разом! Це тільки початок.", "en": "We're learning together! This is just the beginning."},
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
pool = await asyncpg.create_pool(**DB_CONFIG)
|
||||
async with pool.acquire() as conn:
|
||||
# ============ Очищаем старые реакции ============
|
||||
await conn.execute("DELETE FROM teacher_reactions")
|
||||
print("🗑️ Старые реакции удалены")
|
||||
|
||||
# ============ Заполняем wrong_because_explanations ============
|
||||
for item in WRONG_BECAUSE_EXPLANATIONS:
|
||||
await conn.execute("""
|
||||
INSERT INTO wrong_because_explanations (wrong_because, explanation_pl, explanation_ru, explanation_uk, explanation_en)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (wrong_because) DO UPDATE SET
|
||||
explanation_pl = $2,
|
||||
explanation_ru = $3,
|
||||
explanation_uk = $4,
|
||||
explanation_en = $5
|
||||
""", item["wrong_because"], item["pl"], item["ru"], item["uk"], item["en"])
|
||||
|
||||
print(f"✅ Добавлено {len(WRONG_BECAUSE_EXPLANATIONS)} объяснений ошибок")
|
||||
|
||||
# ============ Заполняем teacher_reactions ============
|
||||
correct_count = 0
|
||||
almost_count = 0
|
||||
wrong_count = 0
|
||||
|
||||
for item in TEACHER_REACTIONS:
|
||||
await conn.execute("""
|
||||
INSERT INTO teacher_reactions (reaction_type, text_pl, text_ru, text_uk, text_en)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""", item["type"], item["pl"], item["ru"], item["uk"], item["en"])
|
||||
|
||||
if item["type"] == "correct":
|
||||
correct_count += 1
|
||||
elif item["type"] == "almost":
|
||||
almost_count += 1
|
||||
else:
|
||||
wrong_count += 1
|
||||
|
||||
print(f"✅ Добавлено реакций: correct={correct_count}, almost={almost_count}, wrong={wrong_count}")
|
||||
print(f" Всего: {len(TEACHER_REACTIONS)}")
|
||||
|
||||
await pool.close()
|
||||
print("\n🎉 Готово! Марек готов к работе!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
import csv
|
||||
import asyncpg
|
||||
import asyncio
|
||||
from config import DB_CONFIG
|
||||
|
||||
async def load_all():
|
||||
pool = await asyncpg.create_pool(**DB_CONFIG)
|
||||
async with pool.acquire() as conn:
|
||||
level_files = [
|
||||
("A1", "words_a1.csv"),
|
||||
("A2", "words_a2.csv"),
|
||||
("B1", "words_b1.csv"),
|
||||
]
|
||||
|
||||
total_added = 0
|
||||
|
||||
for level, fname in level_files:
|
||||
print(f'\n{"="*50}')
|
||||
print(f'Loading {fname} (level {level})...')
|
||||
print(f'{"="*50}')
|
||||
|
||||
# 🔧 ФИКС: удаляем ВСЕ строки уровня, независимо от level_id
|
||||
deleted = await conn.execute(
|
||||
"DELETE FROM level_words WHERE level = $1",
|
||||
level
|
||||
)
|
||||
print(f' Deleted old entries: {deleted}')
|
||||
|
||||
count = 0
|
||||
with open(fname, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
word = row['polish'].strip().lower()
|
||||
if word:
|
||||
await conn.execute(
|
||||
'''INSERT INTO level_words (level_id, word_pl, translation_ru, translation_uk, translation_en, level)
|
||||
VALUES (NULL, $1, $2, $3, $4, $5)
|
||||
ON CONFLICT (level, word_pl) DO NOTHING''',
|
||||
word,
|
||||
row.get('russian', ''),
|
||||
row.get('ukrainian', ''),
|
||||
row.get('english', ''),
|
||||
level
|
||||
)
|
||||
count += 1
|
||||
|
||||
total_added += count
|
||||
print(f' Added {count} words from {fname} (level {level})')
|
||||
|
||||
await pool.close()
|
||||
|
||||
print(f'\n🎉 Готово! Всего загружено {total_added} слов.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(load_all())
|
||||
@@ -0,0 +1,286 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from config import FRONTEND_ORIGIN, UPLOADS_DIR
|
||||
from database import init_db
|
||||
from routers import users, game, hints, leaderboard, sentences, achievements, wordquiz, bubblewords, pronunciation, assistant, learning, tts, typing, teacher, support, admin, feed, admin_content
|
||||
from routers.sentences import init_sentences
|
||||
from routers.achievements import init_achievements
|
||||
from routers.learning_data import init_learning_data
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============ Версия ============
|
||||
# Читается из .env (переменная APP_VERSION). Если не задана — fallback "1.0.0".
|
||||
# Фронт живёт на Cloudflare Pages, там своя версия в index.html — они не обязаны совпадать.
|
||||
# При деплое новой версии фронта обнови APP_VERSION в .env и перезапусти filwords-api.
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
||||
|
||||
# ============ Sentry (мониторинг ошибок) ============
|
||||
# ВАЖНО: инициализация ДО создания FastAPI-приложения
|
||||
SENTRY_DSN = os.getenv("SENTRY_DSN", "")
|
||||
if SENTRY_DSN:
|
||||
try:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=[
|
||||
StarletteIntegration(transaction_style="endpoint"),
|
||||
FastApiIntegration(transaction_style="endpoint"),
|
||||
],
|
||||
traces_sample_rate=0.2, # 20% запросов для performance monitoring
|
||||
environment=os.getenv("ENV", "production"),
|
||||
release=f"filwords@{APP_VERSION}",
|
||||
send_default_pii=False, # НЕ отправляем IP/headers/cookies пользователей
|
||||
)
|
||||
logger.info(f"Sentry initialized (release=filwords@{APP_VERSION})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Sentry: {e}")
|
||||
else:
|
||||
logger.warning("SENTRY_DSN not set — Sentry disabled")
|
||||
|
||||
app = FastAPI(title="Filwords PL API")
|
||||
|
||||
# ============ Rate Limiting ============
|
||||
class RateLimiter:
|
||||
def __init__(self):
|
||||
self.requests = defaultdict(list)
|
||||
self.limits = {
|
||||
"default": {"max_requests": 60, "window": 60},
|
||||
"/game/check-word": {"max_requests": 30, "window": 60},
|
||||
"/game/complete-level": {"max_requests": 10, "window": 60},
|
||||
"/sentences/check": {"max_requests": 20, "window": 60},
|
||||
"/leaderboard": {"max_requests": 10, "window": 60},
|
||||
"/assistant/chat": {"max_requests": 20, "window": 60},
|
||||
"/learning/answer": {"max_requests": 30, "window": 60},
|
||||
"/tts": {"max_requests": 30, "window": 60},
|
||||
"/review": {"max_requests": 10, "window": 60},
|
||||
"/typing/check": {"max_requests": 20, "window": 60},
|
||||
"/teacher/answer": {"max_requests": 30, "window": 60},
|
||||
"/teacher/start": {"max_requests": 10, "window": 60},
|
||||
"/teacher/explain": {"max_requests": 10, "window": 60},
|
||||
"/teacher/translate": {"max_requests": 10, "window": 60},
|
||||
"/teacher/example": {"max_requests": 10, "window": 60},
|
||||
"/support/send": {"max_requests": 10, "window": 60},
|
||||
"/support/admin-reply": {"max_requests": 30, "window": 60},
|
||||
"/support/translate": {"max_requests": 20, "window": 60},
|
||||
# 🔧 НОВОЕ (18.09.2026): лимиты для feed
|
||||
"/admin/feed/post": {"max_requests": 20, "window": 60},
|
||||
"/admin/feed/upload-image": {"max_requests": 10, "window": 60},
|
||||
"/admin/feed/translate": {"max_requests": 10, "window": 60},
|
||||
}
|
||||
self._cleanup_task = None
|
||||
|
||||
def _cleanup(self):
|
||||
now = time.time()
|
||||
for key in list(self.requests.keys()):
|
||||
self.requests[key] = [t for t in self.requests[key] if now - t < 60]
|
||||
if not self.requests[key]:
|
||||
del self.requests[key]
|
||||
|
||||
async def start_cleanup(self):
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
self._cleanup()
|
||||
|
||||
def is_allowed(self, key: str, path: str) -> bool:
|
||||
now = time.time()
|
||||
|
||||
limit_config = self.limits.get(path, self.limits["default"])
|
||||
max_requests = limit_config["max_requests"]
|
||||
window = limit_config["window"]
|
||||
|
||||
self.requests[key] = [t for t in self.requests[key] if now - t < window]
|
||||
|
||||
if len(self.requests[key]) >= max_requests:
|
||||
return False
|
||||
|
||||
self.requests[key].append(now)
|
||||
return True
|
||||
|
||||
def get_retry_after(self, key: str, path: str) -> int:
|
||||
if not self.requests[key]:
|
||||
return 0
|
||||
limit_config = self.limits.get(path, self.limits["default"])
|
||||
window = limit_config["window"]
|
||||
oldest = min(self.requests[key])
|
||||
return max(0, int(window - (time.time() - oldest)))
|
||||
|
||||
rate_limiter = RateLimiter()
|
||||
|
||||
# ============ Rate Limiting Middleware ============
|
||||
@app.middleware("http")
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
if path in ["/", "/health"]:
|
||||
return await call_next(request)
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rate_key = f"{client_ip}:{path}"
|
||||
|
||||
if not rate_limiter.is_allowed(rate_key, path):
|
||||
retry_after = rate_limiter.get_retry_after(rate_key, path)
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "Too many requests",
|
||||
"retry_after": retry_after
|
||||
},
|
||||
headers={"Retry-After": str(retry_after)}
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
duration = time.time() - start_time
|
||||
|
||||
if duration > 1.0:
|
||||
logger.warning(f"Slow request: {request.method} {path} - {duration:.2f}s")
|
||||
|
||||
return response
|
||||
|
||||
# ============ CORS ============
|
||||
# 🔧 ОБНОВЛЕНО: добавлен PUT — нужен для редактирования feed-постов
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[FRONTEND_ORIGIN],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ============ 🔧 НОВОЕ (18.09.2026): статика для картинок feed-постов ============
|
||||
# Файлы лежат в /root/filwordspl/backend/uploads/feed/{filename}
|
||||
# URL на фронте: https://api.filwordspl.com/uploads/feed/{filename}
|
||||
app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads")
|
||||
|
||||
# ============ Роутеры ============
|
||||
app.include_router(users.router)
|
||||
app.include_router(game.router)
|
||||
app.include_router(hints.router)
|
||||
app.include_router(leaderboard.router)
|
||||
app.include_router(sentences.router)
|
||||
app.include_router(achievements.router)
|
||||
app.include_router(wordquiz.router)
|
||||
app.include_router(bubblewords.router)
|
||||
app.include_router(pronunciation.router)
|
||||
app.include_router(assistant.router)
|
||||
app.include_router(learning.router)
|
||||
app.include_router(tts.router)
|
||||
app.include_router(typing.router)
|
||||
app.include_router(teacher.router)
|
||||
app.include_router(support.router)
|
||||
app.include_router(admin.router)
|
||||
# 🔧 НОВОЕ (18.09.2026): feed — посты карусели (публичный + админский)
|
||||
app.include_router(feed.router)
|
||||
# 🔧 НОВОЕ (19.09.2026): admin content — контент-менеджер (только для админов)
|
||||
app.include_router(admin_content.router)
|
||||
|
||||
# ============ Фоновое обновление кэша рейтинга ============
|
||||
async def refresh_leaderboard_cache_loop():
|
||||
await asyncio.sleep(10)
|
||||
while True:
|
||||
try:
|
||||
from database import get_pool
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard_cache")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh leaderboard cache: {e}")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
# ============ Startup ============
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await init_db()
|
||||
await init_sentences()
|
||||
await init_achievements()
|
||||
await init_learning_data()
|
||||
|
||||
asyncio.create_task(rate_limiter.start_cleanup())
|
||||
|
||||
from database import get_pool
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Пересоздаём materialized view с новой структурой (с level)
|
||||
await conn.execute("DROP MATERIALIZED VIEW IF EXISTS leaderboard_cache")
|
||||
await conn.execute("""
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS leaderboard_cache AS
|
||||
SELECT
|
||||
u.telegram_id,
|
||||
u.username,
|
||||
u.current_level,
|
||||
COALESCE(ws.total, 0) + COALESCE(s.total, 0) + COALESCE(wq.total, 0) + COALESCE(bw.total, 0) as total_score,
|
||||
COALESCE(ws.total, 0) as wordsearch_score,
|
||||
COALESCE(s.total, 0) as sentence_score,
|
||||
COALESCE(wq.total, 0) as wordquiz_score,
|
||||
COALESCE(wq.words_guessed, 0) as words_guessed,
|
||||
COALESCE(bw.total, 0) as bubblewords_score,
|
||||
COALESCE(bw.levels_completed, 0) as bubble_levels_completed
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT user_id, SUM(score) as total
|
||||
FROM user_progress
|
||||
WHERE completed_at IS NOT NULL
|
||||
GROUP BY user_id
|
||||
) ws ON u.telegram_id = ws.user_id
|
||||
LEFT JOIN (
|
||||
SELECT user_id, SUM(score) as total
|
||||
FROM sentence_progress
|
||||
WHERE completed_at IS NOT NULL
|
||||
GROUP BY user_id
|
||||
) s ON u.telegram_id = s.user_id
|
||||
LEFT JOIN (
|
||||
SELECT user_id, total_score as total, words_guessed
|
||||
FROM word_quiz_progress
|
||||
) wq ON u.telegram_id = wq.user_id
|
||||
LEFT JOIN (
|
||||
SELECT user_id, total_score as total, levels_completed
|
||||
FROM bubble_words_progress
|
||||
) bw ON u.telegram_id = bw.user_id
|
||||
WHERE COALESCE(ws.total, 0) + COALESCE(s.total, 0) + COALESCE(wq.total, 0) + COALESCE(bw.total, 0) > 0
|
||||
ORDER BY total_score DESC
|
||||
""")
|
||||
|
||||
await conn.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_cache_user
|
||||
ON leaderboard_cache (telegram_id)
|
||||
""")
|
||||
|
||||
logger.info("Leaderboard cache created")
|
||||
|
||||
asyncio.create_task(refresh_leaderboard_cache_loop())
|
||||
|
||||
logger.info(f"Filwords API v{APP_VERSION} started")
|
||||
|
||||
# ============ Базовые эндпоинты ============
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"status": "ok", "app": "Filwords PL", "version": APP_VERSION}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
try:
|
||||
from database import get_pool
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.fetchval("SELECT 1")
|
||||
return {"status": "ok", "database": "connected"}
|
||||
except Exception as e:
|
||||
logger.error(f"Healthcheck failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "error", "database": "disconnected"}
|
||||
)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = session
|
||||
asyncio_default_test_loop_scope = session
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
pythonpath = .
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
markers =
|
||||
asyncio: async test
|
||||
integration: tests requiring real DB connection
|
||||
unit: pure logic tests (no DB)
|
||||
@@ -0,0 +1,38 @@
|
||||
aiohappyeyeballs==2.7.1
|
||||
aiohttp==3.14.3
|
||||
aiosignal==1.4.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.8.0
|
||||
anyio==4.14.2
|
||||
asyncpg==0.31.0
|
||||
attrs==26.1.0
|
||||
certifi==2026.7.22
|
||||
click==8.4.2
|
||||
edge-tts==7.2.8
|
||||
fastapi==0.140.0
|
||||
frozenlist==1.8.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.18
|
||||
iniconfig==2.3.0
|
||||
multidict==6.7.1
|
||||
packaging==26.3
|
||||
pillow==12.3.0
|
||||
pluggy==1.6.0
|
||||
propcache==0.5.2
|
||||
pydantic==2.13.4
|
||||
pydantic_core==2.46.4
|
||||
Pygments==2.21.0
|
||||
pytest==9.1.1
|
||||
pytest-asyncio==1.4.0
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.32
|
||||
sentry-sdk==2.69.1
|
||||
starlette==1.3.1
|
||||
tabulate==0.10.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.16.0
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.51.0
|
||||
yarl==1.24.5
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Сброс прогресса пользователя по разделу "Учитель" и "Уроки".
|
||||
Очищает: user_learning_answers, user_lesson_progress, user_topic_progress,
|
||||
user_skill_mastery, learning_sessions, teacher_chat_history, teacher_ai_usage.
|
||||
|
||||
Запуск: python3 reset_my_progress.py
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from config import DB_CONFIG
|
||||
|
||||
# ID пользователя для сброса (твой Telegram ID)
|
||||
USER_ID = 6694989403
|
||||
|
||||
|
||||
async def main():
|
||||
pool = await asyncpg.create_pool(**DB_CONFIG)
|
||||
|
||||
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:
|
||||
print(f"❌ Пользователь {USER_ID} не найден!")
|
||||
await pool.close()
|
||||
return
|
||||
|
||||
# Список таблиц для очистки
|
||||
tables = [
|
||||
"user_learning_answers",
|
||||
"user_lesson_progress",
|
||||
"user_topic_progress",
|
||||
"user_skill_mastery",
|
||||
"learning_sessions",
|
||||
"teacher_chat_history",
|
||||
"teacher_ai_usage",
|
||||
]
|
||||
|
||||
for table in tables:
|
||||
result = await conn.execute(f"""
|
||||
DELETE FROM {table} WHERE user_id = $1
|
||||
""", USER_ID)
|
||||
affected = int(result.split()[-1])
|
||||
print(f"🗑️ {table}: удалено {affected} записей")
|
||||
|
||||
# Также очищаем ai_user_context
|
||||
result = await conn.execute("""
|
||||
DELETE FROM ai_user_context WHERE user_id = $1
|
||||
""", USER_ID)
|
||||
print(f"🗑️ ai_user_context: удалено {result.split()[-1]} записей")
|
||||
|
||||
# Сбрасываем last_completed_level
|
||||
await conn.execute("""
|
||||
UPDATE users SET last_completed_level = 0 WHERE telegram_id = $1
|
||||
""", USER_ID)
|
||||
print("✅ last_completed_level сброшен на 0")
|
||||
|
||||
await pool.close()
|
||||
print("\n🎉 Готово! Можно начинать заново!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
polish,english,russian,ukrainian
|
||||
Cześć jak się masz,Hi how are you,Привет как дела,Привіт як справи
|
||||
Mam na imię Anna,My name is Anna,Меня зовут Анна,Мене звати Анна
|
||||
Miło mi cię poznać,Nice to meet you,Приятно познакомиться,Приємно познайомитися
|
||||
Skąd jesteś,Where are you from,Откуда ты,Звідки ти
|
||||
Jestem z Polski,I am from Poland,Я из Польши,Я з Польщі
|
||||
Ile masz lat,How old are you,Сколько тебе лет,Скільки тобі років
|
||||
Mam trzydzieści cztery lata,I am thirty-four years old,Мне тридцать четыре года,Мені тридцять чотири роки
|
||||
Dziękuję bardzo,Thank you very much,Большое спасибо,Дуже дякую
|
||||
Proszę bardzo,You're welcome,Пожалуйста,Будь ласка
|
||||
Do widzenia,Goodbye,До свидания,До побачення
|
||||
To jest moja rodzina,This is my family,Это моя семья,Це моя сім'я
|
||||
Mam brata i siostrę,I have a brother and a sister,У меня есть брат и сестра,У мене є брат і сестра
|
||||
Moja mama jest lekarzem,My mother is a doctor,Моя мама врач,Моя мама лікарка
|
||||
Mój tata pracuje w biurze,My father works in an office,Мой папа работает в офисе,Мій тато працює в офісі
|
||||
Mam dwoje dzieci,I have two children,У меня двое детей,У мене двоє дітей
|
||||
Moja babcia mieszka w mieście,My grandmother lives in the city,Моя бабушка живёт в городе,Моя бабуся живе в місті
|
||||
To jest mój mąż,This is my husband,Это мой муж,Це мій чоловік
|
||||
To jest moja żona,This is my wife,Это моя жена,Це моя дружина
|
||||
Kocham moją rodzinę,I love my family,Я люблю свою семью,Я люблю свою родину
|
||||
Mój syn ma pięć lat,My son is five years old,Моему сыну пять лет,Моєму сину п'ять років
|
||||
Która jest godzina,What time is it,Который час,Котра година
|
||||
Jest trzecia godzina,It is three o'clock,Сейчас три часа,Зараз третя година
|
||||
Dzisiaj jest poniedziałek,Today is Monday,Сегодня понедельник,Сьогодні понеділок
|
||||
Jutro jest wtorek,Tomorrow is Tuesday,Завтра вторник,Завтра вівторок
|
||||
Mam spotkanie o dziesiątej,I have a meeting at ten,У меня встреча в десять,У мене зустріч о десятій
|
||||
Wstaję o siódmej rano,I get up at seven in the morning,Я встаю в семь утра,Я встаю о сьомій ранку
|
||||
Pracuję od poniedziałku do piątku,I work from Monday to Friday,Я работаю с понедельника по пятницу,Я працюю з понеділка до п'ятниці
|
||||
W sobotę odpoczywam,On Saturday I rest,В субботу я отдыхаю,У суботу я відпочиваю
|
||||
Ile to kosztuje,How much does this cost,Сколько это стоит,Скільки це коштує
|
||||
To kosztuje dziesięć złotych,This costs ten zlotys,Это стоит десять злотых,Це коштує десять злотих
|
||||
Lubię jeść owoce,I like eating fruit,Я люблю есть фрукты,Я люблю їсти фрукти
|
||||
Chcę pić kawę,I want to drink coffee,Я хочу пить кофе,Я хочу пити каву
|
||||
Jem śniadanie o ósmej,I eat breakfast at eight,Я завтракаю в восемь,Я снідаю о восьмій
|
||||
Lubię chleb i ser,I like bread and cheese,Я люблю хлеб и сыр,Я люблю хліб і сир
|
||||
Nie lubię ryb,I don't like fish,Я не люблю рыбу,Я не люблю рибу
|
||||
Poproszę wodę,I'd like water please,Дайте пожалуйста воду,Будь ласка дайте воду
|
||||
Ta zupa jest smaczna,This soup is tasty,Этот суп вкусный,Цей суп смачний
|
||||
Kupuję warzywa na targu,I buy vegetables at the market,Я покупаю овощи на рынке,Я купую овочі на ринку
|
||||
On je jabłko,He is eating an apple,Он ест яблоко,Він їсть яблуко
|
||||
Ona pije herbatę,She is drinking tea,Она пьёт чай,Вона п'є чай
|
||||
Codziennie chodzę do pracy,Every day I go to work,Каждый день я хожу на работу,Щодня я ходжу на роботу
|
||||
Rano myję zęby,In the morning I brush my teeth,Утром я чищу зубы,Вранці я чищу зуби
|
||||
Potem jem śniadanie,Then I eat breakfast,Потом я завтракаю,Потім я снідаю
|
||||
Jadę autobusem do szkoły,I go to school by bus,Я езжу в школу на автобусе,Я їжджу до школи автобусом
|
||||
Po pracy wracam do domu,After work I go back home,После работы я возвращаюсь домой,Після роботи я повертаюся додому
|
||||
Wieczorem oglądam telewizję,In the evening I watch TV,Вечером я смотрю телевизор,Увечері я дивлюся телевізор
|
||||
Idę spać o jedenastej,I go to sleep at eleven,Я ложусь спать в одиннадцать,Я лягаю спати об одинадцятій
|
||||
W weekend sprzątam mieszkanie,On the weekend I clean the flat,На выходных я убираю квартиру,На вихідних я прибираю квартиру
|
||||
Gotuję obiad dla rodziny,I cook dinner for my family,Я готовлю обед для семьи,Я готую обід для родини
|
||||
Czytam książkę przed snem,I read a book before sleep,Я читаю книгу перед сном,Я читаю книгу перед сном
|
||||
Dzisiaj jest ciepło,Today it is warm,Сегодня тепло,Сьогодні тепло
|
||||
Pada deszcz,It is raining,Идёт дождь,Йде дощ
|
||||
Jutro będzie zimno,Tomorrow it will be cold,Завтра будет холодно,Завтра буде холодно
|
||||
Świeci słońce,The sun is shining,Светит солнце,Світить сонце
|
||||
Zimą pada śnieg,In winter it snows,Зимой идёт снег,Взимку йде сніг
|
||||
Lubię lato bo jest gorąco,I like summer because it is hot,Я люблю лето потому что жарко,Я люблю літо бо спекотно
|
||||
Wieje silny wiatr,There is a strong wind,Дует сильный ветер,Дме сильний вітер
|
||||
Jaka jest dzisiaj pogoda,What is the weather like today,Какая сегодня погода,Яка сьогодні погода
|
||||
Wiosną jest ładna pogoda,In spring the weather is nice,Весной хорошая погода,Навесні гарна погода
|
||||
Jesienią jest chłodno,In autumn it is cool,Осенью прохладно,Восени прохолодно
|
||||
Idę do sklepu,I am going to the shop,Я иду в магазин,Я йду до магазину
|
||||
Chcę kupić nowe buty,I want to buy new shoes,Я хочу купить новую обувь,Я хочу купити нове взуття
|
||||
Ta koszula jest za droga,This shirt is too expensive,Эта рубашка слишком дорогая,Ця сорочка занадто дорога
|
||||
Czy macie mniejszy rozmiar,Do you have a smaller size,У вас есть размер поменьше,У вас є менший розмір
|
||||
Płacę kartą,I pay by card,Я плачу картой,Я плачу карткою
|
||||
Ile to kosztuje razem,How much is it in total,Сколько это стоит в общем,Скільки це коштує загалом
|
||||
Ten sklep jest zamknięty,This shop is closed,Этот магазин закрыт,Цей магазин зачинений
|
||||
Supermarket jest blisko domu,The supermarket is close to home,Супермаркет находится близко к дому,Супермаркет знаходиться близько до дому
|
||||
Potrzebuję mleka i chleba,I need milk and bread,Мне нужны молоко и хлеб,Мені потрібні молоко і хліб
|
||||
Ta sukienka jest bardzo ładna,This dress is very pretty,Это платье очень красивое,Ця сукня дуже гарна
|
||||
Mieszkam w Warszawie,I live in Warsaw,Я живу в Варшаве,Я живу у Варшаві
|
||||
Szkoła jest niedaleko parku,The school is near the park,Школа находится недалеко от парка,Школа знаходиться недалеко від парку
|
||||
Biblioteka jest otwarta do szóstej,The library is open until six,Библиотека открыта до шести,Бібліотека відкрита до шостої
|
||||
W mieście jest dużo sklepów,There are many shops in the city,В городе много магазинов,У місті багато магазинів
|
||||
Lubię spacerować w parku,I like walking in the park,Я люблю гулять в парке,Я люблю гуляти в парку
|
||||
Dworzec jest obok szpitala,The station is next to the hospital,Вокзал находится рядом с больницей,Вокзал знаходиться поруч з лікарнею
|
||||
Jedziemy na wakacje nad morze,We are going on holiday to the sea,Мы едем в отпуск на море,Ми їдемо у відпустку на море
|
||||
To muzeum jest bardzo stare,This museum is very old,Этот музей очень старый,Цей музей дуже старий
|
||||
Restauracja jest na rogu ulicy,The restaurant is on the street corner,Ресторан находится на углу улицы,Ресторан знаходиться на розі вулиці
|
||||
Mój dom jest mały ale ładny,My house is small but nice,Мой дом маленький но красивый,Мій дім маленький але гарний
|
||||
Jak masz na imię,What is your name,Как тебя зовут,Як тебе звати
|
||||
Co robisz,What are you doing,Что ты делаешь,Що ти робиш
|
||||
Gdzie mieszkasz,Where do you live,Где ты живёшь,Де ти живеш
|
||||
Czy mówisz po angielsku,Do you speak English,Ты говоришь по-английски,Ти розмовляєш англійською
|
||||
Kim jesteś z zawodu,What is your profession,Кто ты по профессии,Хто ти за професією
|
||||
Czy lubisz muzykę,Do you like music,Тебе нравится музыка,Тобі подобається музика
|
||||
Kiedy masz urodziny,When is your birthday,Когда у тебя день рождения,Коли у тебе день народження
|
||||
Czy masz braci lub siostry,Do you have brothers or sisters,У тебя есть братья или сёстры,У тебе є брати чи сестри
|
||||
Dlaczego jesteś smutny,Why are you sad,Почему ты грустный,Чому ти сумний
|
||||
Co lubisz robić w wolnym czasie,What do you like to do in your free time,Что ты любишь делать в свободное время,Що ти любиш робити у вільний час
|
||||
Moja koszula jest niebieska,My shirt is blue,Моя рубашка синяя,Моя сорочка синя
|
||||
Ten samochód jest czerwony,This car is red,Эта машина красная,Ця машина червона
|
||||
Kot jest czarny i biały,The cat is black and white,Кот чёрно-белый,Кіт чорно-білий
|
||||
Mam zielony plecak,I have a green backpack,У меня зелёный рюкзак,У мене зелений рюкзак
|
||||
Kwiaty w ogrodzie są żółte,The flowers in the garden are yellow,Цветы в саду жёлтые,Квіти в саду жовті
|
||||
To jest mój telefon,This is my phone,Это мой телефон,Це мій телефон
|
||||
Gdzie są moje klucze,Where are my keys,Где мои ключи,Де мої ключі
|
||||
Na stole leży książka,There is a book on the table,На столе лежит книга,На столі лежить книга
|
||||
Mam nowy komputer,I have a new computer,У меня новый компьютер,У мене новий комп'ютер
|
||||
To wszystko na dzisiaj,That's all for today,Это всё на сегодня,Це все на сьогодні
|
||||
To jest mój pokój,This is my room,Это моя комната,Це моя кімната
|
||||
W kuchni gotuję obiad,I cook dinner in the kitchen,Я готовлю обед на кухне,Я готую обід на кухні
|
||||
Łazienka jest obok sypialni,The bathroom is next to the bedroom,Ванная рядом со спальней,Ванна кімната поруч зі спальнею
|
||||
Mam duży salon,I have a big living room,У меня большая гостиная,У мене велика вітальня
|
||||
Okno w moim pokoju jest duże,The window in my room is big,Окно в моей комнате большое,Вікно в моїй кімнаті велике
|
||||
Drzwi są otwarte,The door is open,Дверь открыта,Двері відчинені
|
||||
W domu mam dwa piętra,My house has two floors,В моём доме два этажа,У моєму домі два поверхи
|
||||
Lubię siedzieć na balkonie,I like sitting on the balcony,Я люблю сидеть на балконе,Я люблю сидіти на балконі
|
||||
Ogród jest za domem,The garden is behind the house,Сад находится за домом,Сад знаходиться за будинком
|
||||
Mieszkanie jest na trzecim piętrze,The flat is on the third floor,Квартира на третьем этаже,Квартира на третьому поверсі
|
||||
NoszÄ™ czarne buty,I wear black shoes,Я ношу чёрную обувь,Я ношу чорне взуття
|
||||
Ubieram ciepłą kurtkę zimą,I wear a warm jacket in winter,Зимой я ношу тёплую куртку,Взимку я ношу теплу куртку
|
||||
Ta czapka jest dla ciebie,This hat is for you,Эта шапка для тебя,Ця шапка для тебе
|
||||
Mam nową sukienkę na wesele,I have a new dress for the wedding,У меня новое платье на свадьбу,У мене нова сукня на весілля
|
||||
Te spodnie są za długie,These trousers are too long,Эти брюки слишком длинные,Ці штани занадто довгі
|
||||
Włóż szalik bo jest zimno,Put on a scarf because it's cold,Надень шарф потому что холодно,Одягни шарф бо холодно
|
||||
Lubię nosić kolorowe koszule,I like wearing colorful shirts,Я люблю носить цветные рубашки,Я люблю носити кольорові сорочки
|
||||
Gdzie kupiłeś te rękawiczki,Where did you buy these gloves,Где ты купил эти перчатки,Де ти купив ці рукавички
|
||||
Moje okulary są na stole,My glasses are on the table,Мои очки на столе,Мої окуляри на столі
|
||||
Ten sweter jest bardzo miękki,This sweater is very soft,Этот свитер очень мягкий,Цей светр дуже м'який
|
||||
Idę do szkoły o ósmej,I go to school at eight,Я иду в школу в восемь,Я йду до школи о восьмій
|
||||
Uczę się matematyki,I study mathematics,Я учу математику,Я вивчаю математику
|
||||
Mam dzisiaj trzy lekcje,I have three lessons today,Сегодня у меня три урока,Сьогодні в мене три уроки
|
||||
Nauczycielka jest bardzo miła,The teacher is very nice,Учительница очень добрая,Вчителька дуже добра
|
||||
Piszę zadanie domowe,I am writing my homework,Я пишу домашнее задание,Я пишу домашнє завдання
|
||||
W klasie jest dwadzieścia uczniów,There are twenty students in the class,В классе двадцать учеников,У класі двадцять учнів
|
||||
Lubię przerwę między lekcjami,I like the break between lessons,Мне нравится перерыв между уроками,Мені подобається перерва між уроками
|
||||
Mam plecak pełen książek,I have a backpack full of books,У меня рюкзак полный книг,У мене рюкзак повний книг
|
||||
Egzamin jest w piątek,The exam is on Friday,Экзамен в пятницу,Іспит у п'ятницю
|
||||
Czytamy nową lekturę,We are reading a new book,Мы читаем новую книгу,Ми читаємо нову книгу
|
||||
Mam psa i kota,I have a dog and a cat,У меня есть собака и кот,У мене є собака і кіт
|
||||
Pies biega po parku,The dog is running in the park,Собака бегает по парку,Собака бігає по парку
|
||||
Kot śpi na kanapie,The cat is sleeping on the sofa,Кот спит на диване,Кіт спить на дивані
|
||||
W zoo widzieliśmy słonia,We saw an elephant at the zoo,В зоопарке мы видели слона,У зоопарку ми бачили слона
|
||||
Ptaki śpiewają rano,Birds sing in the morning,Птицы поют утром,Птахи співають вранці
|
||||
Ryby pływają w akwarium,Fish swim in the aquarium,Рыбы плавают в аквариуме,Риби плавають в акваріумі
|
||||
Konie są bardzo silne,Horses are very strong,Лошади очень сильные,Коні дуже сильні
|
||||
Mój królik je marchewkę,My rabbit eats a carrot,Мой кролик ест морковь,Мій кролик їсть моркву
|
||||
Krowy dają mleko,Cows give milk,Коровы дают молоко,Корови дають молоко
|
||||
Lubię oglądać filmy o zwierzętach,I like watching movies about animals,Я люблю смотреть фильмы о животных,Я люблю дивитися фільми про тварин
|
||||
Mam długie włosy,I have long hair,У меня длинные волосы,У мене довге волосся
|
||||
Boli mnie noga,My leg hurts,У меня болит нога,У мене болить нога
|
||||
Jestem bardzo szczęśliwy,I am very happy,Я очень счастлив,Я дуже щасливий
|
||||
Ona jest zmęczona po pracy,She is tired after work,Она устала после работы,Вона втомлена після роботи
|
||||
Czuję się dzisiaj świetnie,I feel great today,Сегодня я чувствую себя отлично,Сьогодні я почуваюся чудово
|
||||
Jestem trochę zdenerwowany,I am a bit nervous,Я немного нервничаю,Я трохи нервую
|
||||
Mam duże oczy i krótkie włosy,I have big eyes and short hair,У меня большие глаза и короткие волосы,У мене великі очі і коротке волосся
|
||||
On jest głodny i chce jeść,He is hungry and wants to eat,Он голоден и хочет есть,Він голодний і хоче їсти
|
||||
Jestem dumny z mojej pracy,I am proud of my work,Я горжусь своей работой,Я пишаюся своєю роботою
|
||||
Czasem czuję się samotny,Sometimes I feel lonely,Иногда я чувствую себя одиноким,Іноді я почуваюся самотнім
|
||||
W niedzielę śpię dłużej,On Sunday I sleep longer,В воскресенье я сплю дольше,У неділю я сплю довше
|
||||
Biorę prysznic każdego ranka,I take a shower every morning,Я принимаю душ каждое утро,Я приймаю душ щоранку
|
||||
RobiÄ™ zakupy w sobotÄ™,I do the shopping on Saturday,Я делаю покупки в субботу,Я роблю покупки в суботу
|
||||
Piszę wiadomość do przyjaciela,I am writing a message to my friend,Я пишу сообщение другу,Я пишу повідомлення другу
|
||||
Dzwonię do mamy co wieczór,I call my mom every evening,Я звоню маме каждый вечер,Я телефоную мамі щовечора
|
||||
Słucham muzyki podczas pracy,I listen to music while working,Я слушаю музыку во время работы,Я слухаю музику під час роботи
|
||||
Ćwiczę jogę rano,I do yoga in the morning,Я занимаюсь йогой утром,Я займаюся йогою вранці
|
||||
Karmię kota przed pracą,I feed the cat before work,Я кормлю кота перед работой,Я годую кота перед роботою
|
||||
Podlewam kwiaty co dwa dni,I water the flowers every two days,Я поливаю цветы каждые два дня,Я поливаю квіти кожні два дні
|
||||
Wychodzę z domu o ósmej,I leave home at eight,Я выхожу из дома в восемь,Я виходжу з дому о восьмій
|
||||
Jadę do pracy samochodem,I drive to work by car,Я езжу на работу на машине,Я їжджу на роботу машиною
|
||||
Wolę pociąg niż autobus,I prefer the train over the bus,Я предпочитаю поезд автобусу,Я віддаю перевагу потягу над автобусом
|
||||
Rower stoi w garażu,The bike is in the garage,Велосипед стоит в гараже,Велосипед стоїть у гаражі
|
||||
Metro jest szybkie i tanie,The subway is fast and cheap,Метро быстрое и дешёвое,Метро швидке і дешеве
|
||||
Czekam na przystanku dziesięć minut,I wait at the stop for ten minutes,Я жду на остановке десять минут,Я чекаю на зупинці десять хвилин
|
||||
Samolot ląduje o północy,The plane lands at midnight,Самолёт приземляется в полночь,Літак приземляється опівночі
|
||||
Taksówka jest droga ale wygodna,A taxi is expensive but comfortable,Такси дорогое но удобное,Таксі дороге але зручне
|
||||
Idę pieszo do sklepu,I walk to the shop,Я иду пешком в магазин,Я йду пішки до магазину
|
||||
Statek płynie do portu,The ship sails to the port,Корабль плывёт в порт,Корабель пливе до порту
|
||||
Muszę zatankować samochód,I have to refuel the car,Мне нужно заправить машину,Мені потрібно заправити машину
|
||||
Zadzwonię do ciebie później,I will call you later,Я позвоню тебе позже,Я подзвоню тобі пізніше
|
||||
Napisz do mnie wiadomość,Write me a message,Напиши мне сообщение,Напиши мені повідомлення
|
||||
Mój telefon jest wyłączony,My phone is off,Мой телефон выключен,Мій телефон вимкнений
|
||||
Nie słyszę cię dobrze,I can't hear you well,Я плохо тебя слышу,Я погано тебе чую
|
||||
Zostaw wiadomość po sygnale,Leave a message after the beep,Оставьте сообщение после сигнала,Залиште повідомлення після сигналу
|
||||
Czy masz mój numer,Do you have my number,У тебя есть мой номер,Ти маєш мій номер
|
||||
Rozmawiamy przez internet,We talk over the internet,Мы разговариваем через интернет,Ми розмовляємо через інтернет
|
||||
Wyślę ci zdjęcie,I will send you a photo,Я пришлю тебе фото,Я надішлю тобі фото
|
||||
Muszę naładować telefon,I have to charge my phone,Мне нужно зарядить телефон,Мені потрібно зарядити телефон
|
||||
Zadzwoń jak dojedziesz,Call when you arrive,Позвони когда приедешь,Подзвони коли приїдеш
|
||||
Lubię słodycze,I like sweets,Я люблю сладости,Я люблю солодощі
|
||||
Nie lubię wstawać wcześnie,I don't like getting up early,Я не люблю рано вставать,Я не люблю рано вставати
|
||||
Uwielbiam podróżować,I love traveling,Я обожаю путешествовать,Я обожнюю подорожувати
|
||||
Nienawidzę czekać w kolejce,I hate waiting in line,Я ненавижу стоять в очереди,Я ненавиджу стояти в черзі
|
||||
Wolę zimę od lata,I prefer winter to summer,Я предпочитаю зиму лету,Я віддаю перевагу зимі над літом
|
||||
Bardzo lubię ten kolor,I really like this color,Мне очень нравится этот цвет,Мені дуже подобається цей колір
|
||||
Nie interesuje mnie polityka,I am not interested in politics,Меня не интересует политика,Мене не цікавить політика
|
||||
Lubimy razem gotować,We like cooking together,Мы любим готовить вместе,Ми любимо готувати разом
|
||||
Ona nie lubi hałasu,She doesn't like noise,Она не любит шум,Вона не любить шум
|
||||
Uwielbiam zapach kawy,I love the smell of coffee,Я обожаю запах кофе,Я обожнюю запах кави
|
||||
Otwórz okno proszę,Open the window please,Открой окно пожалуйста,Відкрий вікно будь ласка
|
||||
Zamknij drzwi,Close the door,Закрой дверь,Закрий двері
|
||||
Usiądź tutaj,Sit here,Сядь здесь,Сядь тут
|
||||
Poczekaj chwilę,Wait a moment,Подожди немного,Зачекай хвилинку
|
||||
Chodź ze mną,Come with me,Пойдём со мной,Ходімо зі мною
|
||||
Nie martw się,Don't worry,Не волнуйся,Не хвилюйся
|
||||
Wszystko będzie dobrze,Everything will be fine,Всё будет хорошо,Все буде добре
|
||||
To jest bardzo proste,This is very simple,Это очень просто,Це дуже просто
|
||||
Masz rację,You are right,Ты прав,Ти маєш рацію
|
||||
Miłego dnia,Have a nice day,Хорошего дня,Гарного дня
|
||||
|
@@ -0,0 +1,101 @@
|
||||
polish,english,russian,ukrainian
|
||||
Wczoraj byłem w kinie,Yesterday I was at the cinema,Вчера я был в кино,Вчора я був у кіно
|
||||
Obejrzeliśmy ciekawy film,We watched an interesting movie,Мы посмотрели интересный фильм,Ми подивилися цікавий фільм
|
||||
W zeszłym roku pojechałem do Włoch,Last year I went to Italy,В прошлом году я ездил в Италию,Минулого року я їздив до Італії
|
||||
Spotkałam starą przyjaciółkę,I met an old friend,Я встретила старую подругу,Я зустріла стару подругу
|
||||
Kupiliśmy nowy samochód,We bought a new car,Мы купили новую машину,Ми купили нову машину
|
||||
Nie mogłem spać całą noc,I couldn't sleep all night,Я не мог спать всю ночь,Я не міг спати всю ніч
|
||||
Skończyłam studia w zeszłym roku,I finished my studies last year,Я закончила учёбу в прошлом году,Я закінчила навчання минулого року
|
||||
Zapomniałem klucza w domu,I forgot my key at home,Я забыл ключ дома,Я забув ключ вдома
|
||||
Rozmawialiśmy o pracy przez godzinę,We talked about work for an hour,Мы разговаривали о работе целый час,Ми розмовляли про роботу цілу годину
|
||||
Ona urodziła się w Krakowie,She was born in Krakow,Она родилась в Кракове,Вона народилася в Кракові
|
||||
W przyszłym tygodniu pojadę do rodziny,Next week I will go to my family,На следующей неделе я поеду к семье,Наступного тижня я поїду до родини
|
||||
Będziemy szukać nowego mieszkania,We will be looking for a new flat,Мы будем искать новую квартиру,Ми будемо шукати нову квартиру
|
||||
Planuję zmienić pracę,I am planning to change jobs,Я планирую сменить работу,Я планую змінити роботу
|
||||
Chciałbym nauczyć się nowego języka,I would like to learn a new language,Я хотел бы выучить новый язык,Я хотів би вивчити нову мову
|
||||
Za miesiąc zaczynam nowy kurs,In a month I am starting a new course,Через месяц я начинаю новый курс,Через місяць я починаю новий курс
|
||||
Mam nadzieję że pogoda będzie dobra,I hope the weather will be good,Я надеюсь что погода будет хорошей,Я сподіваюся що погода буде гарною
|
||||
Zamierzam odwiedzić rodziców w niedzielę,I intend to visit my parents on Sunday,Я собираюсь навестить родителей в воскресенье,Я маю намір відвідати батьків у неділю
|
||||
Będę pracować z domu jutro,I will work from home tomorrow,Завтра я буду работать из дома,Завтра я працюватиму з дому
|
||||
Chcemy kupić dom za kilka lat,We want to buy a house in a few years,Мы хотим купить дом через несколько лет,Ми хочемо купити будинок за кілька років
|
||||
Wkrótce zdam egzamin z polskiego,I will soon pass my Polish exam,Скоро я сдам экзамен по польскому,Скоро я складу іспит з польської
|
||||
Moim zdaniem ten film jest nudny,In my opinion this movie is boring,По-моему этот фильм скучный,На мою думку цей фільм нудний
|
||||
Wolę herbatę niż kawę,I prefer tea over coffee,Я предпочитаю чай кофе,Я віддаю перевагу чаю над кавою
|
||||
Uważam że to dobry pomysł,I think it is a good idea,Я считаю что это хорошая идея,Я вважаю що це гарна ідея
|
||||
Bardziej podoba mi się góra niż morze,I like the mountains more than the sea,Мне больше нравятся горы чем море,Мені більше подобаються гори ніж море
|
||||
Nie zgadzam się z tobą,I don't agree with you,Я не согласен с тобой,Я не погоджуюся з тобою
|
||||
Myślę że powinniśmy odpocząć,I think we should rest,Я думаю нам стоит отдохнуть,Я думаю нам варто відпочити
|
||||
To jest najlepsza restauracja w mieście,This is the best restaurant in the city,Это лучший ресторан в городе,Це найкращий ресторан у місті
|
||||
Wydaje mi się że masz rację,It seems to me that you are right,Мне кажется что ты прав,Мені здається що ти маєш рацію
|
||||
Wolałabym zostać w domu,I would rather stay at home,Я бы предпочла остаться дома,Я б краще залишилась вдома
|
||||
Nie mam nic przeciwko temu,I don't mind that,Я не против этого,Я не проти цього
|
||||
Musimy zarezerwować bilety na pociąg,We have to book train tickets,Нам нужно забронировать билеты на поезд,Нам потрібно забронювати квитки на потяг
|
||||
Samolot odlatuje o szóstej rano,The plane departs at six in the morning,Самолёт вылетает в шесть утра,Літак вилітає о шостій ранку
|
||||
Zgubiliśmy się w nowym mieście,We got lost in the new city,Мы заблудились в новом городе,Ми загубилися в новому місті
|
||||
Hotel był blisko plaży,The hotel was close to the beach,Отель был близко к пляжу,Готель був близько до пляжу
|
||||
Musimy przesiąść się na inny autobus,We have to change to a different bus,Нам нужно пересесть на другой автобус,Нам потрібно пересісти на інший автобус
|
||||
Podróż trwała prawie dziesięć godzin,The journey took almost ten hours,Поездка длилась почти десять часов,Подорож тривала майже десять годин
|
||||
Czy mogę zobaczyć pana paszport,May I see your passport sir,Могу я увидеть ваш паспорт,Чи можу я побачити ваш паспорт
|
||||
Zwiedzaliśmy muzeum przez cały dzień,We visited the museum all day,Мы осматривали музей целый день,Ми оглядали музей цілий день
|
||||
Zapomnieliśmy zabrać mapę,We forgot to bring a map,Мы забыли взять карту,Ми забули взяти карту
|
||||
To była nasza najlepsza podróż,That was our best trip,Это была наша лучшая поездка,Це була наша найкраща подорож
|
||||
Boli mnie głowa,I have a headache,У меня болит голова,У мене болить голова
|
||||
Muszę iść do lekarza,I have to go to the doctor,Мне нужно пойти к врачу,Мені потрібно піти до лікаря
|
||||
Czuję się dzisiaj lepiej,I feel better today,Сегодня я чувствую себя лучше,Сьогодні я почуваюся краще
|
||||
Mam katar i kaszel,I have a runny nose and a cough,У меня насморк и кашель,У мене нежить і кашель
|
||||
Lekarz przepisał mi leki,The doctor prescribed me medicine,Врач выписал мне лекарства,Лікар виписав мені ліки
|
||||
Powinieneś więcej odpoczywać,You should rest more,Тебе следует больше отдыхать,Тобі слід більше відпочивати
|
||||
Złamałem rękę podczas gry w piłkę,I broke my arm while playing football,Я сломал руку во время игры в футбол,Я зламав руку під час гри у футбол
|
||||
Ważne jest jedzenie zdrowej żywności,It is important to eat healthy food,Важно есть здоровую пищу,Важливо їсти здорову їжу
|
||||
Codziennie biegam żeby być zdrowym,I run every day to be healthy,Я бегаю каждый день чтобы быть здоровым,Я бігаю щодня щоб бути здоровим
|
||||
Ona źle się czuje od rana,She has felt unwell since the morning,Она плохо себя чувствует с утра,Вона погано почувається з ранку
|
||||
Pracuję jako nauczyciel w szkole,I work as a teacher at a school,Я работаю учителем в школе,Я працюю вчителем у школі
|
||||
Mam dużo pracy w tym tygodniu,I have a lot of work this week,У меня много работы на этой неделе,У мене багато роботи цього тижня
|
||||
Uczę się polskiego od roku,I have been learning Polish for a year,Я учу польский уже год,Я вивчаю польську вже рік
|
||||
Muszę przygotować raport na jutro,I have to prepare a report for tomorrow,Мне нужно подготовить отчёт на завтра,Мені потрібно підготувати звіт на завтра
|
||||
Mój szef jest bardzo wymagający,My boss is very demanding,Мой начальник очень требовательный,Мій керівник дуже вимогливий
|
||||
Studiuję informatykę na uniwersytecie,I study computer science at university,Я изучаю информатику в университете,Я вивчаю інформатику в університеті
|
||||
Mam egzamin w przyszłym tygodniu,I have an exam next week,У меня экзамен на следующей неделе,У мене іспит наступного тижня
|
||||
Pracujemy razem nad nowym projektem,We are working together on a new project,Мы вместе работаем над новым проектом,Ми разом працюємо над новим проєктом
|
||||
Codziennie chodzę na zajęcia,I go to classes every day,Я каждый день хожу на занятия,Я щодня ходжу на заняття
|
||||
Trudno jest znaleźć dobrą pracę,It is difficult to find a good job,Трудно найти хорошую работу,Важко знайти хорошу роботу
|
||||
Ten dom jest większy niż tamten,This house is bigger than that one,Этот дом больше чем тот,Цей будинок більший ніж той
|
||||
Zima jest zimniejsza niż jesień,Winter is colder than autumn,Зима холоднее осени,Зима холодніша за осінь
|
||||
Moja siostra jest młodsza ode mnie,My sister is younger than me,Моя сестра младше меня,Моя сестра молодша за мене
|
||||
Ten pociąg jest szybszy niż autobus,This train is faster than the bus,Этот поезд быстрее автобуса,Цей потяг швидший за автобус
|
||||
To jest najdroższy sklep w mieście,This is the most expensive shop in the city,Это самый дорогой магазин в городе,Це найдорожчий магазин у місті
|
||||
On mówi lepiej po angielsku niż ja,He speaks English better than me,Он говорит по-английски лучше меня,Він говорить англійською краще за мене
|
||||
To zadanie jest trudniejsze od poprzedniego,This task is harder than the previous one,Это задание сложнее предыдущего,Це завдання складніше за попереднє
|
||||
Wolę tę kawiarnię bo jest cichsza,I prefer this café because it is quieter,Я предпочитаю это кафе потому что оно тише,Я віддаю перевагу цій кав'ярні бо вона тихіша
|
||||
Ten telefon jest tańszy niż tamten,This phone is cheaper than that one,Этот телефон дешевле того,Цей телефон дешевший за той
|
||||
To był najlepszy dzień w moim życiu,That was the best day of my life,Это был лучший день в моей жизни,Це був найкращий день у моєму житті
|
||||
W wolnym czasie gram na gitarze,In my free time I play guitar,В свободное время я играю на гитаре,У вільний час я граю на гітарі
|
||||
Uwielbiam czytać kryminały,I love reading crime novels,Я обожаю читать детективы,Я обожнюю читати детективи
|
||||
Co weekend jeżdżę na rowerze,Every weekend I ride a bike,Каждые выходные я катаюсь на велосипеде,Кожні вихідні я катаюся на велосипеді
|
||||
Interesuję się fotografią,I am interested in photography,Я интересуюсь фотографией,Я цікавлюся фотографією
|
||||
Lubię gotować dla przyjaciół,I like cooking for friends,Я люблю готовить для друзей,Я люблю готувати для друзів
|
||||
Chodzę na siłownię trzy razy w tygodniu,I go to the gym three times a week,Я хожу в спортзал три раза в неделю,Я ходжу до спортзалу тричі на тиждень
|
||||
Moim hobby jest malowanie,My hobby is painting,Моё хобби рисование,Моє хобі малювання
|
||||
Gramy w piłkę nożną w niedziele,We play football on Sundays,Мы играем в футбол по воскресеньям,Ми граємо у футбол по неділях
|
||||
Ona uwielbia tańczyć,She loves dancing,Она обожает танцевать,Вона обожнює танцювати
|
||||
Zbieram stare monety,I collect old coins,Я собираю старые монеты,Я збираю старі монети
|
||||
Przepraszam jak dojść do dworca,Excuse me how do I get to the station,Извините как добраться до вокзала,Перепрошую як дістатися до вокзалу
|
||||
Proszę iść prosto a potem skręcić w lewo,Please go straight and then turn left,Идите прямо а потом поверните налево,Ідіть прямо а потім поверніть ліворуч
|
||||
Czy może mi pan pomóc,Can you help me sir,Вы можете мне помочь,Чи можете ви мені допомогти
|
||||
Nie wiem gdzie jest najbliższy przystanek,I don't know where the nearest stop is,Я не знаю где ближайшая остановка,Я не знаю де найближча зупинка
|
||||
To jest bardzo daleko stąd,It is very far from here,Это очень далеко отсюда,Це дуже далеко звідси
|
||||
Czy mógłby pan powtórzyć,Could you repeat sir,Не могли бы вы повторить,Чи могли б ви повторити
|
||||
Poszukuję apteki w pobliżu,I am looking for a pharmacy nearby,Я ищу аптеку поблизости,Я шукаю аптеку поблизу
|
||||
Dziękuję za pomoc,Thank you for the help,Спасибо за помощь,Дякую за допомогу
|
||||
Zgubiłem drogę do hotelu,I lost my way to the hotel,Я заблудился по дороге в отель,Я загубив дорогу до готелю
|
||||
Ile czasu zajmuje dojazd do centrum,How long does it take to get downtown,Сколько времени занимает дорога до центра,Скільки часу займає дорога до центру
|
||||
Mój komputer się zepsuł,My computer broke down,Мой компьютер сломался,Мій комп'ютер зламався
|
||||
Nie mogę znaleźć kluczy,I can't find my keys,Я не могу найти ключи,Я не можу знайти ключі
|
||||
Zapomniałem hasła do konta,I forgot my account password,Я забыл пароль от аккаунта,Я забув пароль від акаунта
|
||||
Autobus się spóźnia,The bus is late,Автобус опаздывает,Автобус запізнюється
|
||||
Skończył mi się czas,I ran out of time,У меня закончилось время,У мене закінчився час
|
||||
Mam problem z internetem,I have a problem with the internet,У меня проблема с интернетом,У мене проблема з інтернетом
|
||||
Zepsuła się pralka,The washing machine broke,Стиральная машина сломалась,Пральна машина зламалася
|
||||
Musimy naprawić dach,We have to fix the roof,Нам нужно починить крышу,Нам потрібно полагодити дах
|
||||
Sąsiedzi są bardzo głośni,The neighbors are very loud,Соседи очень шумные,Сусіди дуже галасливі
|
||||
To był naprawdę trudny dzień,That was a really difficult day,Это был действительно трудный день,Це був справді важкий день
|
||||
|
@@ -0,0 +1,101 @@
|
||||
polish,english,russian,ukrainian
|
||||
Gdybym miał więcej czasu podróżowałbym częściej,If I had more time I would travel more often,Если бы у меня было больше времени я бы путешествовал чаще,Якби я мав більше часу я б подорожував частіше
|
||||
Gdyby padało zostalibyśmy w domu,If it rained we would stay at home,Если бы шёл дождь мы бы остались дома,Якби йшов дощ ми б залишились удома
|
||||
Zrobiłbym to inaczej gdybym mógł,I would do it differently if I could,Я бы сделал это по-другому если бы мог,Я б зробив це по-іншому якби міг
|
||||
Gdybyś mnie zapytał pomógłbym ci,If you had asked me I would have helped you,Если бы ты меня спросил я бы тебе помог,Якби ти мене запитав я б тобі допоміг
|
||||
Co byś zrobił na moim miejscu,What would you do in my place,Что бы ты сделал на моём месте,Що б ти зробив на моєму місці
|
||||
Gdyby nie deszcz poszlibyśmy na spacer,If it weren't for the rain we would go for a walk,Если бы не дождь мы бы пошли гулять,Якби не дощ ми б пішли на прогулянку
|
||||
Chciałbym żeby to było prostsze,I wish it were simpler,Я хотел бы чтобы это было проще,Я хотів би щоб це було простіше
|
||||
Gdybym wygrał na loterii kupiłbym dom,If I won the lottery I would buy a house,Если бы я выиграл в лотерею я бы купил дом,Якби я виграв у лотерею я б купив будинок
|
||||
Na twoim miejscu porozmawiałbym z szefem,In your place I would talk to the boss,На твоём месте я бы поговорил с начальником,На твоєму місці я б поговорив з керівником
|
||||
Gdyby wszyscy się starali świat byłby lepszy,If everyone tried the world would be better,Если бы все старались мир был бы лучше,Якби всі старались світ був би кращим
|
||||
Uważam że edukacja jest najważniejsza,I believe education is most important,Я считаю что образование важнее всего,Я вважаю що освіта найважливіша
|
||||
Moim zdaniem warto inwestować w siebie,In my opinion it's worth investing in yourself,По-моему стоит инвестировать в себя,На мою думку варто інвестувати в себе
|
||||
Zgadzam się z tym stwierdzeniem tylko częściowo,I agree with this statement only partially,Я согласен с этим утверждением лишь частично,Я погоджуюся з цим твердженням лише частково
|
||||
Z jednej strony to dobry pomysł z drugiej ryzykowny,On one hand it's a good idea on the other it's risky,С одной стороны это хорошая идея с другой рискованная,З одного боку це гарна ідея з іншого ризикована
|
||||
Wydaje mi się że przesadzasz,It seems to me that you are exaggerating,Мне кажется что ты преувеличиваешь,Мені здається що ти перебільшуєш
|
||||
Trudno powiedzieć kto ma rację,It's hard to say who is right,Трудно сказать кто прав,Важко сказати хто має рацію
|
||||
Nie da się tego zrobić bez wsparcia innych,It's impossible to do this without others' support,Это невозможно сделать без поддержки других,Це неможливо зробити без підтримки інших
|
||||
Według mnie powinniśmy zmienić nasze podejście,In my view we should change our approach,По-моему нам следует изменить наш подход,На мою думку нам слід змінити наш підхід
|
||||
Argument ten wydaje się dość przekonujący,This argument seems quite convincing,Этот аргумент кажется довольно убедительным,Цей аргумент здається досить переконливим
|
||||
Nie zawsze zgadzam się z opinią większości,I don't always agree with the majority opinion,Я не всегда согласен с мнением большинства,Я не завжди погоджуюся з думкою більшості
|
||||
Awansowałem po dwóch latach pracy,I got promoted after two years of work,Я получил повышение через два года работы,Я отримав підвищення через два роки роботи
|
||||
Zmiana pracy była dla mnie dużym wyzwaniem,Changing jobs was a big challenge for me,Смена работы была для меня большим вызовом,Зміна роботи була для мене великим викликом
|
||||
Rozważam założenie własnej firmy,I am considering starting my own company,Я подумываю открыть собственную фирму,Я розглядаю можливість відкрити власну фірму
|
||||
Praca zdalna daje mi więcej wolności,Remote work gives me more freedom,Удалённая работа даёт мне больше свободы,Віддалена робота дає мені більше свободи
|
||||
Współpraca w zespole nie zawsze jest łatwa,Teamwork is not always easy,Работа в команде не всегда даётся легко,Робота в команді не завжди дається легко
|
||||
Muszę rozwijać swoje umiejętności zawodowe,I need to develop my professional skills,Мне нужно развивать свои профессиональные навыки,Мені потрібно розвивати свої професійні навички
|
||||
Wypalenie zawodowe dotyka coraz więcej ludzi,Burnout affects more and more people,Профессиональное выгорание затрагивает всё больше людей,Професійне вигорання торкається дедалі більшої кількості людей
|
||||
Negocjacje trwały kilka godzin,The negotiations lasted several hours,Переговоры длились несколько часов,Переговори тривали кілька годин
|
||||
Ostatecznie zdecydowałem się zostać w tej firmie,In the end I decided to stay at this company,В итоге я решил остаться в этой компании,Зрештою я вирішив залишитися в цій компанії
|
||||
Praca daje mi poczucie spełnienia,Work gives me a sense of fulfillment,Работа даёт мне ощущение самореализации,Робота дає мені відчуття самореалізації
|
||||
Powinniśmy ograniczyć zużycie plastiku,We should reduce plastic consumption,Нам следует сократить потребление пластика,Нам слід скоротити споживання пластику
|
||||
Zmiany klimatyczne dotyczą nas wszystkich,Climate change concerns all of us,Изменение климата касается всех нас,Зміна клімату стосується всіх нас
|
||||
Segreguję śmieci od kilku lat,I have been sorting trash for several years,Я сортирую мусор уже несколько лет,Я сортую сміття вже кілька років
|
||||
Coraz więcej ludzi rezygnuje z samochodu,More and more people are giving up cars,Всё больше людей отказываются от машины,Дедалі більше людей відмовляються від машини
|
||||
Zanieczyszczenie powietrza wpływa na nasze zdrowie,Air pollution affects our health,Загрязнение воздуха влияет на наше здоровье,Забруднення повітря впливає на наше здоров'я
|
||||
Warto sadzić drzewa w miastach,It's worth planting trees in cities,Стоит сажать деревья в городах,Варто садити дерева в містах
|
||||
Musimy oszczędzać wodę na co dzień,We must save water every day,Нам нужно экономить воду каждый день,Нам потрібно економити воду щодня
|
||||
Odnawialne źródła energii stają się popularniejsze,Renewable energy sources are becoming more popular,Возобновляемые источники энергии становятся всё популярнее,Відновлювані джерела енергії стають дедалі популярнішими
|
||||
Ludzie coraz bardziej dbają o środowisko,People are increasingly taking care of the environment,Люди всё больше заботятся об окружающей среде,Люди дедалі більше турбуються про довкілля
|
||||
To miasto wprowadziło zakaz jednorazowych toreb,This city introduced a ban on single-use bags,Этот город ввёл запрет на одноразовые пакеты,Це місто запровадило заборону на одноразові пакети
|
||||
Sztuczna inteligencja zmienia nasz sposób pracy,Artificial intelligence is changing the way we work,Искусственный интеллект меняет то как мы работаем,Штучний інтелект змінює те як ми працюємо
|
||||
Nie wyobrażam sobie życia bez telefonu,I can't imagine life without a phone,Я не представляю жизни без телефона,Я не уявляю життя без телефону
|
||||
Wiele osób spędza za dużo czasu w internecie,Many people spend too much time online,Многие люди проводят слишком много времени в интернете,Багато людей проводять забагато часу в інтернеті
|
||||
Ta aplikacja pomaga mi organizować dzień,This app helps me organize my day,Это приложение помогает мне организовать день,Цей додаток допомагає мені організувати день
|
||||
Technologia rozwija się szybciej niż kiedykolwiek,Technology is developing faster than ever,Технологии развиваются быстрее чем когда-либо,Технології розвиваються швидше ніж будь-коли
|
||||
Praca zdalna stała się możliwa dzięki internetowi,Remote work became possible thanks to the internet,Удалённая работа стала возможной благодаря интернету,Віддалена робота стала можливою завдяки інтернету
|
||||
Powinniśmy uważać na to co udostępniamy online,We should be careful about what we share online,Нам следует быть осторожными с тем что мы публикуем в сети,Нам слід бути обережними з тим що ми публікуємо в мережі
|
||||
Media społecznościowe wpływają na nasze samopoczucie,Social media affects our wellbeing,Социальные сети влияют на наше самочувствие,Соціальні мережі впливають на наше самопочуття
|
||||
Kupiłem nowy laptop bo stary się zepsuł,I bought a new laptop because the old one broke,Я купил новый ноутбук потому что старый сломался,Я купив новий ноутбук бо старий зламався
|
||||
Roboty coraz częściej zastępują ludzi w pracy,Robots are increasingly replacing people at work,Роботы всё чаще заменяют людей на работе,Роботи дедалі частіше замінюють людей на роботі
|
||||
W wiadomościach mówili o nowych przepisach,The news talked about new regulations,В новостях говорили о новых правилах,У новинах говорили про нові правила
|
||||
Bezrobocie w tym regionie znacznie wzrosło,Unemployment in this region has increased significantly,Безработица в этом регионе значительно выросла,Безробіття в цьому регіоні значно зросло
|
||||
Wielu ludzi protestowało przeciwko podwyżkom cen,Many people protested against price increases,Многие люди протестовали против повышения цен,Багато людей протестували проти підвищення цін
|
||||
Rząd ogłosił nowe reformy edukacyjne,The government announced new educational reforms,Правительство объявило о новых реформах образования,Уряд оголосив нові освітні реформи
|
||||
Nierówności społeczne są coraz bardziej widoczne,Social inequalities are becoming more and more visible,Социальное неравенство становится всё более заметным,Соціальна нерівність стає дедалі помітнішою
|
||||
Wybory odbędą się w przyszłym miesiącu,The elections will take place next month,Выборы состоятся в следующем месяце,Вибори відбудуться наступного місяця
|
||||
Media często przesadzają z opisem wydarzeń,The media often exaggerate the description of events,Медиа часто преувеличивают описание событий,Медіа часто перебільшують опис подій
|
||||
Coraz więcej młodych ludzi emigruje za pracą,More and more young people emigrate for work,Всё больше молодых людей эмигрируют за работой,Дедалі більше молодих людей емігрують за роботою
|
||||
Społeczeństwo powinno wspierać osoby starsze,Society should support elderly people,Общество должно поддерживать пожилых людей,Суспільство повинно підтримувати літніх людей
|
||||
Ta decyzja wywołała wiele kontrowersji,This decision caused a lot of controversy,Это решение вызвало много споров,Це рішення викликало багато суперечок
|
||||
Podróżowanie nauczyło mnie otwartości na inne kultury,Traveling taught me to be open to other cultures,Путешествия научили меня открытости к другим культурам,Подорожі навчили мене відкритості до інших культур
|
||||
W tym kraju ludzie mają inne zwyczaje niż my,People in this country have different customs than us,В этой стране у людей другие обычаи чем у нас,У цій країні в людей інші звичаї ніж у нас
|
||||
Byłem zaskoczony gościnnością mieszkańców,I was surprised by the hospitality of the residents,Я был удивлён гостеприимством жителей,Я був здивований гостинністю мешканців
|
||||
Ta podróż zmieniła moje spojrzenie na życie,This trip changed my outlook on life,Эта поездка изменила мой взгляд на жизнь,Ця подорож змінила мій погляд на життя
|
||||
Zwiedziliśmy stare miasto pełne zabytków,We visited the old town full of historical monuments,Мы осмотрели старый город полный достопримечательностей,Ми оглянули старе місто повне пам'яток
|
||||
Lokalna kuchnia bardzo mi się spodobała,I really liked the local cuisine,Мне очень понравилась местная кухня,Мені дуже сподобалась місцева кухня
|
||||
Warto poznawać tradycje innych narodów,It's worth learning about the traditions of other nations,Стоит узнавать традиции других народов,Варто пізнавати традиції інших народів
|
||||
Podczas podróży poznałem ludzi z całego świata,During the trip I met people from all over the world,Во время поездки я познакомился с людьми со всего мира,Під час подорожі я познайомився з людьми з усього світу
|
||||
Ten festiwal przyciąga turystów z całej Europy,This festival attracts tourists from all over Europe,Этот фестиваль привлекает туристов со всей Европы,Цей фестиваль приваблює туристів з усієї Європи
|
||||
Kultura tego regionu jest bardzo zróżnicowana,The culture of this region is very diverse,Культура этого региона очень разнообразна,Культура цього регіону дуже різноманітна
|
||||
Przyjaźń wymaga zaufania i cierpliwości,Friendship requires trust and patience,Дружба требует доверия и терпения,Дружба вимагає довіри й терпіння
|
||||
Rzadko się kłócimy ale gdy tak długo nie rozmawiamy,We rarely argue but when we do we don't talk for a long time,Мы редко ссоримся но когда это случается долго не разговариваем,Ми рідко сваримося але коли так довго не розмовляємо
|
||||
Trudno jest utrzymać kontakt z dawnymi znajomymi,It's difficult to stay in touch with old acquaintances,Трудно поддерживать связь со старыми знакомыми,Важко підтримувати зв'язок зі старими знайомими
|
||||
Nasza relacja z czasem stała się silniejsza,Our relationship has become stronger over time,Наши отношения со временем стали крепче,Наші стосунки з часом стали міцнішими
|
||||
Warto rozmawiać otwarcie o swoich uczuciach,It's worth talking openly about your feelings,Стоит открыто говорить о своих чувствах,Варто відкрито говорити про свої почуття
|
||||
Czasami trudno zrozumieć punkt widzenia drugiej osoby,Sometimes it's hard to understand another person's point of view,Иногда трудно понять точку зрения другого человека,Іноді важко зрозуміти точку зору іншої людини
|
||||
Znamy się od dzieciństwa,We have known each other since childhood,Мы знакомы с детства,Ми знайомі з дитинства
|
||||
Wsparcie rodziny pomogło mi przetrwać trudny czas,My family's support helped me survive a difficult time,Поддержка семьи помогла мне пережить трудное время,Підтримка родини допомогла мені пережити важкий час
|
||||
Zaufanie buduje się latami a traci w chwilę,Trust is built over years and lost in a moment,Доверие строится годами а теряется в один миг,Довіра будується роками а втрачається за мить
|
||||
Nie każda znajomość musi przerodzić się w przyjaźń,Not every acquaintance has to turn into a friendship,Не каждое знакомство должно перерасти в дружбу,Не кожне знайомство має перерости в дружбу
|
||||
Marzę o tym żeby zwiedzić cały świat,I dream of traveling the whole world,Я мечтаю объехать весь мир,Я мрію об'їхати весь світ
|
||||
Mam nadzieję osiągnąć sukces w tej dziedzinie,I hope to achieve success in this field,Я надеюсь добиться успеха в этой области,Я сподіваюся досягти успіху в цій галузі
|
||||
Planuję zdobyć nowe kwalifikacje zawodowe,I plan to gain new professional qualifications,Я планирую получить новую профессиональную квалификацию,Я планую здобути нову професійну кваліфікацію
|
||||
Moim celem jest otworzenie własnej działalności,My goal is to start my own business,Моя цель открыть собственный бизнес,Моя мета відкрити власний бізнес
|
||||
Chciałbym w przyszłości mieszkać za granicą,I would like to live abroad in the future,В будущем я хотел бы жить за границей,У майбутньому я хотів би жити за кордоном
|
||||
Staram się realizować swoje marzenia krok po kroku,I try to pursue my dreams step by step,Я стараюсь воплощать свои мечты шаг за шагом,Я намагаюся втілювати свої мрії крок за кроком
|
||||
Wierzę że ciężka praca się opłaca,I believe that hard work pays off,Я верю что тяжёлый труд окупается,Я вірю що важка праця окупається
|
||||
Postawiłem sobie ambitny cel na ten rok,I set myself an ambitious goal for this year,Я поставил себе амбициозную цель на этот год,Я поставив собі амбітну мету на цей рік
|
||||
Nie boję się zmian wręcz przeciwnie ich szukam,I am not afraid of change quite the opposite I seek it,Я не боюсь перемен наоборот ищу их,Я не боюся змін навпаки шукаю їх
|
||||
Wierzę że za kilka lat będę w innym miejscu w życiu,I believe that in a few years I will be in a different place in life,Я верю что через несколько лет буду в другом месте в жизни,Я вірю що за кілька років буду в іншому місці в житті
|
||||
Czas płynie szybciej niż nam się wydaje,Time flies faster than we think,Время летит быстрее чем нам кажется,Час минає швидше ніж нам здається
|
||||
Szczęście nie zawsze zależy od pieniędzy,Happiness doesn't always depend on money,Счастье не всегда зависит от денег,Щастя не завжди залежить від грошей
|
||||
Każdy z nas popełnia błędy i uczy się na nich,Each of us makes mistakes and learns from them,Каждый из нас совершает ошибки и учится на них,Кожен з нас робить помилки і вчиться на них
|
||||
Warto doceniać to co się ma,It's worth appreciating what you have,Стоит ценить то что имеешь,Варто цінувати те що маєш
|
||||
Wolność wiąże się z odpowiedzialnością,Freedom is connected with responsibility,Свобода связана с ответственностью,Свобода пов'язана з відповідальністю
|
||||
Trudne doświadczenia często czynią nas silniejszymi,Difficult experiences often make us stronger,Трудный опыт часто делает нас сильнее,Важкий досвід часто робить нас сильнішими
|
||||
Nie zawsze łatwo jest podjąć właściwą decyzję,It's not always easy to make the right decision,Не всегда легко принять правильное решение,Не завжди легко прийняти правильне рішення
|
||||
Ludzie zmieniają się wraz z upływem czasu,People change as time passes,Люди меняются с течением времени,Люди змінюються з плином часу
|
||||
Sens życia każdy definiuje inaczej,Everyone defines the meaning of life differently,Каждый по-своему определяет смысл жизни,Кожен по-своєму визначає сенс життя
|
||||
Warto czasem zatrzymać się i pomyśleć o priorytetach,Sometimes it's worth stopping to think about priorities,Иногда стоит остановиться и подумать о приоритетах,Іноді варто зупинитися і подумати про пріоритети
|
||||
|
@@ -0,0 +1 @@
|
||||
"""Tests package for Filwords PL backend."""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Общие фикстуры для тестов Filwords PL.
|
||||
Использует реальную БД (dev), но с изолированными тестовыми данными.
|
||||
После каждого теста данные удаляются.
|
||||
"""
|
||||
import pytest
|
||||
from config import DB_CONFIG
|
||||
from database import get_pool, init_db
|
||||
|
||||
|
||||
# Тестовые ID — чтобы не пересекаться с реальными данными
|
||||
TEST_USER_ID = 999999999
|
||||
TEST_TOPIC_ID = "test_topic_zzz"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
async def setup_database():
|
||||
"""Один раз на сессию: инициализирует БД (миграции)."""
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def conn():
|
||||
"""Соединение с БД для одного теста."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as connection:
|
||||
yield connection
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(conn):
|
||||
"""Создаёт тестового юзера, удаляет после теста."""
|
||||
await conn.execute("""
|
||||
INSERT INTO users (telegram_id, username, app_language, current_level)
|
||||
VALUES ($1, 'test_user', 'ru', 'A2')
|
||||
ON CONFLICT (telegram_id) DO NOTHING
|
||||
""", TEST_USER_ID)
|
||||
|
||||
yield TEST_USER_ID
|
||||
|
||||
# Cleanup — удаляем всё, что связано с юзером.
|
||||
# ВАЖНО: порядок — сначала дочерние таблицы (FK), потом родительские.
|
||||
await conn.execute("DELETE FROM user_skill_mastery WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM user_learning_answers WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM user_lesson_progress WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM user_topic_progress WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM learning_sessions WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM users WHERE telegram_id = $1", TEST_USER_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_session(conn, test_user):
|
||||
"""Тестовая сессия обучения (для FK в user_learning_answers)."""
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO learning_sessions (user_id, level_code, topic_id, session_type)
|
||||
VALUES ($1, 'A2', 'test_topic', 'lesson')
|
||||
RETURNING id
|
||||
""", test_user)
|
||||
|
||||
session_id = row["id"]
|
||||
yield session_id
|
||||
|
||||
# 🔧 ФИКС: сначала удаляем ответы, ссылающиеся на сессию (FK),
|
||||
# только потом саму сессию
|
||||
await conn.execute("DELETE FROM user_learning_answers WHERE session_id = $1", session_id)
|
||||
await conn.execute("DELETE FROM learning_sessions WHERE id = $1", session_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_topic(conn):
|
||||
"""Создаёт тестовый топик, удаляет после теста."""
|
||||
await conn.execute("""
|
||||
INSERT INTO learning_topics (topic_id, level_code, order_number, name_pl, name_ru, name_uk, name_en)
|
||||
VALUES ($1, 'A2', 999, 'Test Topic', 'Тестовый топик', 'Тестовий топік', 'Test Topic')
|
||||
ON CONFLICT (topic_id) DO NOTHING
|
||||
""", TEST_TOPIC_ID)
|
||||
|
||||
yield TEST_TOPIC_ID
|
||||
|
||||
await conn.execute("DELETE FROM learning_lessons WHERE topic_id = $1", TEST_TOPIC_ID)
|
||||
await conn.execute("DELETE FROM learning_topics WHERE topic_id = $1", TEST_TOPIC_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def create_test_lesson(conn, test_topic):
|
||||
"""
|
||||
Фабрика: создаёт урок с произвольным skill_tag.
|
||||
Использование: await create_test_lesson("test_skill")
|
||||
"""
|
||||
created_ids = []
|
||||
|
||||
async def _create(skill_tag: str, order: int = 1) -> str:
|
||||
lesson_id = f"test_lesson_{skill_tag}"
|
||||
await conn.execute("""
|
||||
INSERT INTO learning_lessons
|
||||
(lesson_id, topic_id, order_number, skill_tag,
|
||||
title_pl, title_ru, title_uk, title_en,
|
||||
explanation_pl, explanation_ru, explanation_uk, explanation_en)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
'Test Lesson', 'Тестовый урок', 'Тестовий урок', 'Test Lesson',
|
||||
'Test', 'Test', 'Test', 'Test')
|
||||
ON CONFLICT (lesson_id) DO NOTHING
|
||||
""", lesson_id, test_topic, order, skill_tag)
|
||||
created_ids.append(lesson_id)
|
||||
return lesson_id
|
||||
|
||||
yield _create
|
||||
|
||||
for lid in created_ids:
|
||||
await conn.execute("DELETE FROM learning_lessons WHERE lesson_id = $1", lid)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def clean_mastery(conn, test_user):
|
||||
"""Очищает user_skill_mastery для тестового юзера до и после теста."""
|
||||
await conn.execute("DELETE FROM user_skill_mastery WHERE user_id = $1", test_user)
|
||||
yield
|
||||
await conn.execute("DELETE FROM user_skill_mastery WHERE user_id = $1", test_user)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Тесты для check_and_award_achievements.
|
||||
Проверяют выдачу ачивок без дублей.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
async def test_achievement_not_awarded_twice(conn, test_user):
|
||||
"""
|
||||
Если ачивка уже выдана — второй раз не выдаётся.
|
||||
"""
|
||||
from routers.achievements import check_and_award_achievements
|
||||
|
||||
# Чистим ачивки тестового юзера
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
# Первый раз
|
||||
result1 = await check_and_award_achievements(test_user, "levels_completed", 1000)
|
||||
|
||||
# Второй раз с тем же значением
|
||||
result2 = await check_and_award_achievements(test_user, "levels_completed", 1000)
|
||||
|
||||
# Второй раз должен вернуть пустой список
|
||||
assert len(result2) == 0
|
||||
|
||||
# Cleanup
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
|
||||
async def test_achievement_awarded_for_matching_value(conn, test_user):
|
||||
"""
|
||||
Ачивка выдаётся, если value соответствует требованию.
|
||||
"""
|
||||
from routers.achievements import check_and_award_achievements
|
||||
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
# Уровень 1 завершён — ачивка "first_level" (levels_completed >= 1)
|
||||
result = await check_and_award_achievements(test_user, "levels_completed", 1)
|
||||
|
||||
# Должна быть минимум одна ачивка (first_level)
|
||||
assert len(result) >= 1
|
||||
assert any("first" in a["code"].lower() or "level" in a["code"].lower() for a in result)
|
||||
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
|
||||
async def test_achievement_returns_localized_name(conn, test_user):
|
||||
"""
|
||||
Название ачивки возвращается на языке пользователя (RU).
|
||||
"""
|
||||
from routers.achievements import check_and_award_achievements
|
||||
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
await conn.execute("UPDATE users SET app_language = 'ru' WHERE telegram_id = $1", test_user)
|
||||
|
||||
result = await check_and_award_achievements(test_user, "levels_completed", 1)
|
||||
|
||||
assert len(result) >= 1
|
||||
# Название должно быть непустой строкой (RU)
|
||||
assert result[0]["name"] # не пусто
|
||||
assert isinstance(result[0]["name"], str)
|
||||
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
|
||||
async def test_achievement_unknown_type_returns_empty(conn, test_user):
|
||||
"""
|
||||
Неизвестный тип требования → пустой список.
|
||||
"""
|
||||
from routers.achievements import check_and_award_achievements
|
||||
|
||||
result = await check_and_award_achievements(test_user, "nonexistent_type_xyz", 999)
|
||||
|
||||
assert result == []
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Тесты для update_skill_mastery.
|
||||
Проверяют формулы расчёта mastery, retention, trend.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ХЕЛПЕР: вставить ответ напрямую в БД
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def insert_answer(
|
||||
conn,
|
||||
user_id: int,
|
||||
session_id: int,
|
||||
skill_tag: str,
|
||||
correct: bool,
|
||||
question_type: str = "recognition",
|
||||
wrong_because: str | None = None,
|
||||
days_ago: int = 0,
|
||||
):
|
||||
"""Вставляет один ответ в user_learning_answers."""
|
||||
created_at = datetime.now() - timedelta(days=days_ago)
|
||||
await conn.execute("""
|
||||
INSERT INTO user_learning_answers (
|
||||
user_id, session_id, level_code, topic_id, question_id,
|
||||
selected_answer, correct, skill_tag, question_type,
|
||||
context_type, difficulty, time_taken_ms, hint_used,
|
||||
wrong_because, is_final_answer, created_at
|
||||
) VALUES (
|
||||
$1, $2, 'A2', 'test_topic', 'test_q',
|
||||
'x', $3, $4, $5,
|
||||
'lesson_practice', 1, 5000, FALSE,
|
||||
$6, TRUE, $7
|
||||
)
|
||||
""", user_id, session_id, correct, skill_tag, question_type, wrong_because, created_at)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ТЕСТЫ
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def test_mastery_no_answers_returns_none(conn, test_user, clean_mastery):
|
||||
"""Если у юзера нет ответов по skill_tag — функция возвращает None."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "nonexistent_skill")
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_mastery_100_percent_accuracy(conn, test_user, test_session, clean_mastery):
|
||||
"""Если все ответы правильные — accuracy = 100, mastery = высокая."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
for _ in range(5):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result is not None
|
||||
assert result["attempts"] == 5
|
||||
assert result["accuracy"] == 100.0
|
||||
assert result["mastery"] >= 90.0
|
||||
|
||||
|
||||
async def test_mastery_0_percent_accuracy(conn, test_user, test_session, clean_mastery):
|
||||
"""Если все ответы неправильные — accuracy = 0, mastery = низкая."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
for _ in range(5):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=False, wrong_because="wrong_case")
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result is not None
|
||||
assert result["accuracy"] == 0.0
|
||||
assert result["mastery"] <= 30.0
|
||||
|
||||
|
||||
async def test_mastery_50_percent_accuracy(conn, test_user, test_session, clean_mastery):
|
||||
"""50% accuracy → mastery в среднем диапазоне."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
for i in range(6):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=(i % 2 == 0))
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result is not None
|
||||
assert 40.0 <= result["accuracy"] <= 60.0
|
||||
assert 40.0 <= result["mastery"] <= 80.0
|
||||
|
||||
|
||||
async def test_mastery_retention_fresh(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последний ответ был сегодня — retention_status = 'fresh'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=0)
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=0)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["retention_status"] == "fresh"
|
||||
|
||||
|
||||
async def test_mastery_retention_needs_review(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последний ответ 10 дней назад — retention_status = 'needs_review'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=10)
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=10)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["retention_status"] == "needs_review"
|
||||
|
||||
|
||||
async def test_mastery_retention_overdue(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последний ответ 20 дней назад — retention_status = 'overdue'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=20)
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=20)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["retention_status"] == "overdue"
|
||||
|
||||
|
||||
async def test_mastery_trend_improving(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последние 3 ответа лучше, чем общая точность — trend = 'improving'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
# 7 старых неправильных, 3 свежих правильных
|
||||
for _ in range(7):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=False, days_ago=5)
|
||||
for _ in range(3):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=0)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
# 3 правильных из 10 = 30% accuracy, но recent_accuracy = 100% → improving
|
||||
assert result["trend"] == "improving"
|
||||
|
||||
|
||||
async def test_mastery_trend_declining(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последние 3 ответа хуже, чем общая точность — trend = 'declining'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
# 7 старых правильных, 3 свежих неправильных
|
||||
for _ in range(7):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=5)
|
||||
for _ in range(3):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=False, days_ago=0)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["trend"] == "declining"
|
||||
|
||||
|
||||
async def test_mastery_trend_insufficient_data(conn, test_user, test_session, clean_mastery):
|
||||
"""Если меньше 3 ответов — trend = 'insufficient_data'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True)
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["trend"] == "insufficient_data"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Тесты для get_next_lesson.
|
||||
Проверяют приоритеты: overdue → weak → test → next.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ХЕЛПЕРЫ
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def set_user_level(conn, user_id: int, level: str):
|
||||
await conn.execute("UPDATE users SET current_level = $1 WHERE telegram_id = $2", level, user_id)
|
||||
|
||||
|
||||
async def insert_mastery(
|
||||
conn,
|
||||
user_id: int,
|
||||
skill_tag: str,
|
||||
mastery: float,
|
||||
retention: str = "fresh",
|
||||
attempts: int = 3,
|
||||
days_since: int = 0,
|
||||
):
|
||||
"""Вставляет запись в user_skill_mastery."""
|
||||
last_attempt = datetime.now() - timedelta(days=days_since)
|
||||
await conn.execute("""
|
||||
INSERT INTO user_skill_mastery (
|
||||
user_id, skill_tag, mastery_score, retention_status,
|
||||
attempts_count, last_attempt_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id, skill_tag) DO UPDATE SET
|
||||
mastery_score = $3,
|
||||
retention_status = $4,
|
||||
attempts_count = $5,
|
||||
last_attempt_at = $6
|
||||
""", user_id, skill_tag, mastery, retention, attempts, last_attempt)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ТЕСТЫ
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def test_orchestrator_prioritizes_overdue(conn, test_user, create_test_lesson, clean_mastery):
|
||||
"""Если есть overdue навык — он выбирается первым."""
|
||||
from routers.orchestrator import get_next_lesson
|
||||
|
||||
await set_user_level(conn, test_user, "A2")
|
||||
await create_test_lesson("test_skill")
|
||||
await insert_mastery(conn, test_user, "test_skill", mastery=50.0, retention="overdue", days_since=20)
|
||||
|
||||
result = await get_next_lesson(conn, test_user)
|
||||
|
||||
assert result["type"] == "review"
|
||||
assert result["reason"] == "overdue"
|
||||
|
||||
|
||||
async def test_orchestrator_prioritizes_weak(conn, test_user, create_test_lesson, clean_mastery):
|
||||
"""Если нет overdue, но есть weak (mastery < 75) — выбирается он."""
|
||||
from routers.orchestrator import get_next_lesson
|
||||
|
||||
await set_user_level(conn, test_user, "A2")
|
||||
await create_test_lesson("test_skill")
|
||||
await insert_mastery(conn, test_user, "test_skill", mastery=50.0, retention="fresh", days_since=1)
|
||||
|
||||
result = await get_next_lesson(conn, test_user)
|
||||
|
||||
assert result["type"] == "review"
|
||||
assert result["reason"] == "weak_mastery"
|
||||
|
||||
|
||||
async def test_orchestrator_prefers_overdue_over_weak(conn, test_user, create_test_lesson, clean_mastery):
|
||||
"""Если есть и overdue, и weak — overdue важнее."""
|
||||
from routers.orchestrator import get_next_lesson
|
||||
|
||||
await set_user_level(conn, test_user, "A2")
|
||||
await create_test_lesson("weak_skill", order=1)
|
||||
await create_test_lesson("overdue_skill", order=2)
|
||||
|
||||
await insert_mastery(conn, test_user, "weak_skill", mastery=40.0, retention="fresh", days_since=1)
|
||||
await insert_mastery(conn, test_user, "overdue_skill", mastery=60.0, retention="overdue", days_since=20)
|
||||
|
||||
result = await get_next_lesson(conn, test_user)
|
||||
|
||||
assert result["reason"] == "overdue"
|
||||
|
||||
|
||||
async def test_orchestrator_weak_requires_min_attempts(conn, test_user, create_test_lesson, clean_mastery):
|
||||
"""Weak skill требует минимум 2 попытки — если меньше, он не выбирается."""
|
||||
from routers.orchestrator import get_next_lesson
|
||||
|
||||
await set_user_level(conn, test_user, "A2")
|
||||
await create_test_lesson("test_skill")
|
||||
await insert_mastery(conn, test_user, "test_skill", mastery=50.0, retention="fresh", attempts=1)
|
||||
|
||||
result = await get_next_lesson(conn, test_user)
|
||||
|
||||
assert result["reason"] != "weak_mastery"
|
||||
+758
@@ -0,0 +1,758 @@
|
||||
polish,english,russian,ukrainian
|
||||
adres,address,адрес,адреса
|
||||
akapit,paragraph,абзац,абзац
|
||||
akcja,action,действие,дія
|
||||
aktor,actor,актёр,актор
|
||||
aktorka,actress,актриса,акторка
|
||||
aktywność,activity,активность,активність
|
||||
ale,but,но,але
|
||||
artykuł,article,статья,стаття
|
||||
artysta,artist,артист,артист
|
||||
autobus,bus,автобус,автобус
|
||||
babcia,grandmother,бабушка,бабуся
|
||||
banan,banana,банан,банан
|
||||
bardzo,very,очень,дуже
|
||||
basen,swimming pool,бассейн,басейн
|
||||
będę,I will be,я буду,я буду
|
||||
bez,without,без,без
|
||||
biały,white,белый,білий
|
||||
biblioteka,library,библиотека,бібліотека
|
||||
biedny,poor,бедный,бідний
|
||||
biegać,to run,бегать,бігати
|
||||
bilet,ticket,билет,квиток
|
||||
biurko,desk,письменный стол,письмовий стіл
|
||||
biuro,office,офис,офіс
|
||||
biznes,business,бизнес,бізнес
|
||||
błąd,mistake / error,ошибка,помилка
|
||||
blisko,close / near,близко,близько
|
||||
blond,blond / blonde,светловолосый,білявий
|
||||
bogaty,rich,богатый,багатий
|
||||
brać,to take,брать,брати
|
||||
brat,brother,брат,брат
|
||||
brązowy,brown,коричневый,коричневий
|
||||
brudny,dirty,грязный,брудний
|
||||
budować,to build,строить,будувати
|
||||
budynek,building,здание,будівля
|
||||
but,shoe,туфля / ботинок,черевик / туфля
|
||||
butelka,bottle,бутылка,пляшка
|
||||
cebula,onion,лук,цибуля
|
||||
cena,price,цена,ціна
|
||||
centrum,centre / center,центр,центр
|
||||
chciałbym,I would like,я хотел бы,я хотів би
|
||||
chcieć,to want,хотеть,хотіти
|
||||
chleb,bread,хлеб,хліб
|
||||
chłodny,cool,прохладный,прохолодний
|
||||
chłopak,boy / boyfriend,парень,хлопець
|
||||
chłopiec,boy,мальчик,хлопчик
|
||||
chodzić,to walk / to go regularly,ходить,ходити
|
||||
chory,sick / ill,больной,хворий
|
||||
ciąć,to cut,резать,різати
|
||||
ciało,body,тело,тіло
|
||||
ciasto,cake,торт,торт
|
||||
cichy,quiet,тихий,тихий
|
||||
ciemny,dark,тёмный,темний
|
||||
ciepły,warm,тёплый,теплий
|
||||
ciężarówka,truck,грузовик,вантажівка
|
||||
ciocia,aunt,тётя,тітка
|
||||
cokolwiek,anything,что угодно,що завгодно
|
||||
córka,daughter,дочь,донька
|
||||
coś,something,что-то,щось
|
||||
cudowny,wonderful,чудесный,чудовий
|
||||
cukier,sugar,сахар,цукор
|
||||
ćwiartka,quarter,четверть,чверть
|
||||
ćwiczenie,exercise,упражнение,вправа
|
||||
czarny,black,чёрный,чорний
|
||||
czas,time,время,час
|
||||
czasami,sometimes,иногда,іноді
|
||||
czekać,wait,ждать,чекати
|
||||
czekolada,chocolate,шоколад,шоколад
|
||||
czerwiec,June,июнь,червень
|
||||
czerwony,red,красный,червоний
|
||||
cześć,hello; hi,привет,привіт
|
||||
część,part; piece,часть,частина
|
||||
często,often,часто,часто
|
||||
czterdzieści,forty,сорок,сорок
|
||||
czternaście,fourteen,четырнадцать,чотирнадцять
|
||||
cztery,four,четыре,чотири
|
||||
czujny,alert; vigilant,бдительный,пильний
|
||||
Czwartek,Thursday,четверг,четвер
|
||||
czwarty,fourth,четвёртый,четвертий
|
||||
czysty,clean,чистый,чистий
|
||||
czytać,read,читать,читати
|
||||
czytanie,reading,чтение,читання
|
||||
czytelnik,reader,читатель,читач
|
||||
dać,give,дать,дати
|
||||
daleko,far,далеко,далеко
|
||||
darmowy,free,бесплатный,безкоштовний
|
||||
decydować,decide,решать,вирішувати
|
||||
deszcz,rain,дождь,дощ
|
||||
dialog,dialogue,диалог,діалог
|
||||
dieta,diet,диета,дієта
|
||||
dla,for,для,для
|
||||
dlaczego,why,почему,чому
|
||||
długi,long,длинный,довгий
|
||||
długopis,pen,ручка,ручка
|
||||
do,to; until,до,до
|
||||
dobry,good,хороший,добрий
|
||||
dobrze,well; okay,хорошо,добре
|
||||
dodać,add,добавить,додати
|
||||
dodatkowy,additional,дополнительный,додатковий
|
||||
dołączyć,join,присоединиться,приєднатися
|
||||
dom,house; home,дом,дім
|
||||
dorosły,adult,взрослый,дорослий
|
||||
dość,enough,достаточно,достатньо
|
||||
doskonały,excellent; perfect,отличный; идеальный,відмінний; ідеальний
|
||||
dostać,get; receive,получить,отримати
|
||||
dowolny,any,любой,будь-який
|
||||
droga,road; way,дорога; путь,дорога; шлях
|
||||
drogi,expensive; dear,дорогой,дорогий
|
||||
drużyna,team,команда,команда
|
||||
drzewo,tree,дерево,дерево
|
||||
drzwi,door,дверь,двері
|
||||
dużo,a lot; much; many,много,багато
|
||||
duży,big; large,большой,великий
|
||||
dwa,two,два,два
|
||||
dwadzieścia,twenty,двадцать,двадцять
|
||||
dwanaście,twelve,двенадцать,дванадцять
|
||||
dyskutować,discuss,обсуждать,обговорювати
|
||||
dziadek,grandfather,дедушка,дідусь
|
||||
dziecko,child,ребёнок,дитина
|
||||
dziękować,thank,благодарить,дякувати
|
||||
dziękuję,thank you,спасибо,дякую
|
||||
dzielić,divide; share,делить; делиться,ділити; ділитися
|
||||
dzień,day,день,день
|
||||
dziesięć,ten,десять,десять
|
||||
dziewczyna,girl,девушка; девочка,дівчина; дівчинка
|
||||
dziewięć,nine,девять,дев'ять
|
||||
dziewięćdziesiąt,ninety,девяносто,дев'яносто
|
||||
dziewiętnaście,nineteen,девятнадцать,дев'ятнадцять
|
||||
dżinsy,jeans,джинсы,джинси
|
||||
dziś,today,сегодня,сьогодні
|
||||
dźwięk,sound,звук,звук
|
||||
egzamin,exam,экзамен,іспит
|
||||
ekscytujący,exciting,захватывающий,захопливий
|
||||
fakt,fact,факт,факт
|
||||
fałszywy,false; fake,ложный; поддельный,хибний; підроблений
|
||||
fantastyczny,fantastic,фантастический; замечательный,фантастичний; чудовий
|
||||
farba,paint,краска,фарба
|
||||
farma,farm,ферма,ферма
|
||||
festiwal,festival,фестиваль,фестиваль
|
||||
film,film; movie,фильм,фільм
|
||||
finałowy,final,финальный,фінальний
|
||||
fioletowy,purple,фиолетовый,фіолетовий
|
||||
firma,company; firm,компания,компанія
|
||||
forma,form; shape,форма,форма
|
||||
fortepian,piano,фортепиано,фортепіано
|
||||
fotografia,photograph; photography,фотография; фотография как искусство,фотографія; фотографія як мистецтво
|
||||
fraza,phrase,фраза,фраза
|
||||
funt,pound,фунт,фунт
|
||||
gazeta,newspaper,газета,газета
|
||||
gdzie,where,где,де
|
||||
geografia,geography,география,географія
|
||||
gitara,guitar,гитара,гітара
|
||||
głodny,hungry,голодный,голодний
|
||||
głowa,head,голова,голова
|
||||
główny,main; main/primary,главный,головний
|
||||
godzina,hour; o'clock,час,година
|
||||
góra,mountain; hill,гора; холм,гора; пагорб
|
||||
gorący,hot,горячий,гарячий
|
||||
gotować,to cook,готовить,готувати
|
||||
gotowanie,cooking,готовка; приготовление пищи,готування
|
||||
gotowy,ready,готовый,готовий
|
||||
gra,game; play,игра,гра
|
||||
grać,to play,играть,грати
|
||||
gracz,player,игрок,гравець
|
||||
gruby,fat; thick,толстый,товстий
|
||||
grudzień,December,декабрь,грудень
|
||||
grupa,group,группа,група
|
||||
gwiazda,star,звезда,зірка
|
||||
hej,hey; hi,эй; привет,гей; привіт
|
||||
herbata,tea,чай,чай
|
||||
historia,history; story,история; рассказ,історія; розповідь
|
||||
hobby,hobby,хобби,хобі
|
||||
hotel,hotel,отель; гостиница,готель
|
||||
ich,their,их,їхній
|
||||
imię,name; first name,имя,ім'я
|
||||
impreza,party; event,вечеринка; мероприятие,вечірка; захід
|
||||
informacja,information,информация,інформація
|
||||
inny,other; different,другой; иной,інший
|
||||
inteligentny,intelligent; smart,умный; интеллектуальный,розумний; інтелектуальний
|
||||
interesujący,interesting,интересный,цікавий
|
||||
internet,internet,интернет,інтернет
|
||||
iść,to go; to walk,идти,йти
|
||||
ją,her,её,її
|
||||
jabłko,apple,яблоко,яблуко
|
||||
jajko,egg,яйцо,яйце
|
||||
jak,how; like,как,як
|
||||
jeden,one; one (masculine),один,один
|
||||
jedenaście,eleven,одиннадцать,одинадцять
|
||||
jednak,however; nevertheless,однако; всё же,однак; все ж
|
||||
jedzenie,food; eating,еда; питание,їжа; харчування
|
||||
jego,his,его,його
|
||||
jeść,to eat,есть,їсти
|
||||
jeśli,if,если,якщо
|
||||
jeździć,to travel; to ride; to go regularly,ездить; путешествовать,їздити; подорожувати
|
||||
język,language; tongue,язык,мова; язик
|
||||
jutro,tomorrow,завтра,завтра
|
||||
kanapka,sandwich,бутерброд; сэндвич,бутерброд; сендвіч
|
||||
kapelusz,hat,шляпа,капелюх
|
||||
kąpiel,bath; bathing,ванна; купание,ванна; купання
|
||||
kariera,career,карьера,кар'єра
|
||||
karta,card; menu,карта; карточка; меню,картка; меню
|
||||
kawa,coffee,кофе,кава
|
||||
kawał,piece; bit,кусок; немного,шматок; трохи
|
||||
kawiarnia,café; coffee shop,кафе,кав'ярня; кафе
|
||||
każdy,every; each; everyone,каждый,кожен
|
||||
kelner,waiter,официант,офіціант
|
||||
kiedy,when,когда,коли
|
||||
kiedykolwiek,ever; at any time,когда-либо,коли-небудь
|
||||
kierowca,driver,водитель,водій
|
||||
kilka,several; a few,несколько,кілька
|
||||
klasa,class,класс,клас
|
||||
klub,club,клуб,клуб
|
||||
klucz,key,ключ,ключ
|
||||
kobieta,woman,женщина,жінка
|
||||
kochać,to love,любить,кохати; любити
|
||||
kolacja,dinner; supper,ужин,вечеря
|
||||
kolor,color,цвет,колір
|
||||
komputer,computer,компьютер,комп'ютер
|
||||
koń,horse,лошадь,кінь
|
||||
koncert,concert,концерт,концерт
|
||||
koniec,end,конец,кінець
|
||||
koszt,cost,стоимость; цена,вартість; ціна
|
||||
koszula,shirt,рубашка,сорочка
|
||||
koszykówka,basketball,баскетбол,баскетбол
|
||||
kot,cat,кот; кошка,кіт; кішка
|
||||
kraj,country; land,страна; государство,країна; держава
|
||||
krótki,short,короткий,короткий
|
||||
krowa,cow,корова,корова
|
||||
krzesło,chair,стул,стілець
|
||||
książka,book,книга,книга
|
||||
kto,who,кто,хто
|
||||
który,which; which one,который; какой,який; котрий
|
||||
ktoś,someone; somebody,кто-то; кто-нибудь,хтось; хтось
|
||||
kubek,cup; mug,чашка,чашка
|
||||
kuchnia,kitchen; cuisine,кухня; кухня (национальная),кухня
|
||||
kultura,culture,культура,культура
|
||||
kupić,to buy,купить,купити
|
||||
kurczak,chicken,курица; цыплёнок,курка; курча
|
||||
kurs,course; course of study,курс,курс
|
||||
kurtka,jacket,куртка,куртка
|
||||
kuzyn,cousin (male),двоюродный брат; кузен,двоюрідний брат; кузен
|
||||
kwiat,flower,цветок,квітка
|
||||
kwiecień,April,апрель,квітень
|
||||
ładny,pretty; nice,красивый; симпатичный,гарний; симпатичний
|
||||
lato,summer,лето,літо
|
||||
łatwy,easy,лёгкий,легкий
|
||||
łazienka,bathroom,ванная комната,ванна кімната
|
||||
lekarz,doctor,врач,лікар
|
||||
lekcja,lesson,урок,урок
|
||||
lepszy,better,лучший; лучше,кращий; краще
|
||||
lew,lion,лев,лев
|
||||
leżeć,to lie; to be lying,лежать,лежати
|
||||
licznik,meter; counter,счётчик; измерительный прибор,лічильник; вимірювальний прилад
|
||||
linia,line,линия; линия,лінія
|
||||
lipiec,July,июль,липень
|
||||
list,letter,письмо,лист
|
||||
lista,list,список,список
|
||||
listopad,November,ноябрь,листопад
|
||||
literować,to spell,произносить по буквам,називати по літерах
|
||||
lód,ice,лёд,лід
|
||||
lody,ice cream,мороженое,морозиво
|
||||
łódź,boat,лодка,човен
|
||||
lokalny,local,местный,місцевий
|
||||
lot,flight,полёт; рейс,політ; рейс
|
||||
lotnisko,airport,аэропорт,аеропорт
|
||||
łóżko,bed,кровать,ліжко
|
||||
lub,or,или,або
|
||||
lubić,to like,любить; нравиться,любити; подобатися
|
||||
ludzie,people,люди,люди
|
||||
lunch,lunch,обед; ланч,обід; ланч
|
||||
luty,February,февраль,лютий
|
||||
magazyn,magazine; warehouse,журнал; склад,журнал; склад
|
||||
maj,May,май,травень
|
||||
mały,small; little,маленький,малий
|
||||
mama,mum; mother,мама,мама
|
||||
mapa,map,карта,карта
|
||||
marchew,carrot,морковь,морква
|
||||
marzec,March,март,березень
|
||||
masło,butter,сливочное масло,вершкове масло
|
||||
maszyna,machine,машина; механизм,машина; механізм
|
||||
matka,mother,мать,мати
|
||||
mąż,husband,муж,чоловік
|
||||
menu,menu,меню,меню
|
||||
mężczyzna,man,мужчина,чоловік
|
||||
miasto,city; town,город,місто
|
||||
mieć,to have,иметь,мати
|
||||
miejsce,place; seat,место,місце
|
||||
miesiąc,month,месяц,місяць
|
||||
mięso,meat,мясо,м'ясо
|
||||
mieszkanie,flat; apartment,квартира,квартира
|
||||
mila,mile,миля,миля
|
||||
milion,million,миллион,мільйон
|
||||
miły,nice; kind,милый; добрый,милий; добрий
|
||||
minuta,minute,минута,хвилина
|
||||
mleko,milk,молоко,молоко
|
||||
młody,young,молодой,молодий
|
||||
mnie,me,меня; мне,мене; мені
|
||||
móc,to be able to; can,мочь,могти
|
||||
model,model,модель,модель
|
||||
mógł,could; was able to,мог,міг
|
||||
mój,my,мой,мій
|
||||
moment,moment,момент,момент
|
||||
mówić,to speak; to say,говорить,говорити
|
||||
może,maybe; perhaps,может быть; возможно,можливо; мабуть
|
||||
możliwy,possible,возможный,можливий
|
||||
musieć,to have to; must,быть вынужденным; должен,мусити; бути змушеним
|
||||
muzeum,museum,музей,музей
|
||||
muzyka,music,музыка,музика
|
||||
my,we,мы,ми
|
||||
myć,to wash,мыть,мити
|
||||
myśleć,to think,думать,думати
|
||||
nad,over; above; by,над; выше; у,над; біля
|
||||
najlepszy,best,лучший,найкращий
|
||||
najpierw,first; firstly,сначала; сперва,спочатку
|
||||
najwięcej,the most,больше всего; наиболее,найбільше
|
||||
napełniać,to fill,наполнять,наповнювати
|
||||
naprawdę,really,действительно; правда,справді; насправді
|
||||
nas,us,нас,нас
|
||||
następny,next,следующий,наступний
|
||||
nasz,our,наш,наш
|
||||
naturalny,natural,естественный; натуральный,природний; натуральний
|
||||
nauczyciel,teacher,учитель,учитель
|
||||
nauka,learning; science,учёба; наука,навчання; наука
|
||||
naukowiec,scientist,учёный,науковець
|
||||
nawet,even,даже,навіть
|
||||
negatywny,negative,негативный,негативний
|
||||
nic,nothing,ничего; ничто,нічого; ніщо
|
||||
nie,no; not,не; нет,не; ні
|
||||
niebezpieczny,dangerous,опасный,небезпечний
|
||||
niebieski,blue,синий; голубой,синій; блакитний
|
||||
niedziela,Sunday,воскресенье,неділя
|
||||
niektóre,some; certain,некоторые,деякі
|
||||
niemowlę,baby; infant,младенец,немовля
|
||||
nienawidzić,to hate,ненавидеть,ненавидіти
|
||||
niesamowity,amazing; incredible,невероятный; потрясающий,неймовірний; дивовижний
|
||||
nigdy,never,никогда,ніколи
|
||||
nikt,nobody; no one,никто,ніхто
|
||||
niż,than,чем; нежели,ніж
|
||||
noc,night,ночь,ніч
|
||||
noga,leg; foot,нога,нога
|
||||
nos,nose,нос,ніс
|
||||
nosić,to wear; to carry,носить,носити
|
||||
notatka,note,заметка; записка,нотатка; записка
|
||||
nowoczesny,modern,современный,сучасний
|
||||
nowy,new,новый,новий
|
||||
nudny,boring,скучный,нудний
|
||||
numer,number,номер; число,номер; число
|
||||
obiekt,object; facility,объект,об'єкт
|
||||
obok,next to; beside,рядом; возле,поруч; біля
|
||||
obraz,picture; image,картина; изображение,картина; зображення
|
||||
obszar,area,область; район,область; район
|
||||
ocean,ocean,океан,океан
|
||||
odpowiedzieć,to answer; to reply,ответить,відповісти
|
||||
odwiedzać,to visit,посещать; навещать,відвідувати
|
||||
odwiedzający,visitor,посетитель,відвідувач
|
||||
odwrotny,opposite; reverse,обратный; противоположный,зворотний; протилежний
|
||||
ogień,fire,огонь,вогонь
|
||||
oglądać,to watch; to look at,смотреть,дивитися
|
||||
ogród,garden,сад,сад
|
||||
ojciec,father,отец,батько
|
||||
okno,window,окно,вікно
|
||||
oko,eye,глаз,око
|
||||
okres,period; period of time,период; промежуток времени,період; проміжок часу
|
||||
ołówek,pencil,карандаш,олівець
|
||||
on,he,он,він
|
||||
ona,she,она,вона
|
||||
oni,they; they (masculine personal),они; они (мужчины/мужская группа),вони; вони (чоловічо-особова група)
|
||||
opinia,opinion,мнение,думка
|
||||
opis,description,описание,опис
|
||||
opisać,to describe,описать,описати
|
||||
opowieść,story; tale,рассказ; история,розповідь; історія
|
||||
opublikować,to publish,опубликовать,опублікувати
|
||||
ortografia,spelling; orthography,правописание,правопис
|
||||
osiem,eight,восемь,вісім
|
||||
osiemdziesiąt,eighty,восемьдесят,вісімдесят
|
||||
osiemnaście,eighteen,восемнадцать,вісімнадцять
|
||||
osoba,person,человек; лицо,особа; людина
|
||||
osobisty,personal,личный,особистий
|
||||
ostatni,last; final,последний; заключительный,останній; заключний
|
||||
oświadczenie,statement; declaration,заявление; утверждение,заява; твердження
|
||||
otworzyć,to open,открыть,відкрити
|
||||
owca,sheep,овца,вівця
|
||||
owoc,fruit,фрукт,фрукт
|
||||
oznaczać,to mean; to indicate,означать; обозначать,означати; позначати
|
||||
pamiętać,to remember,помнить,пам'ятати
|
||||
papier,paper,бумага,папір
|
||||
para,pair; couple,пара,пара
|
||||
parasol,umbrella,зонт,парасоля
|
||||
park,park,парк,парк
|
||||
partner,partner,партнёр,партнер
|
||||
pasować,to fit; to suit,подходить,підходити
|
||||
paszport,passport,паспорт,паспорт
|
||||
patrzeć,to look; to watch,смотреть,дивитися
|
||||
październik,October,октябрь,жовтень
|
||||
pełny,full,полный,повний
|
||||
piątek,Friday,пятница,п’ятниця
|
||||
piąty,fifth,пятый,п’ятий
|
||||
pić,to drink,пить,пити
|
||||
pięć,five,пять,п’ять
|
||||
pięćdziesiąt,fifty,пятьдесят,п’ятдесят
|
||||
piękny,beautiful,красивый,красивий
|
||||
pielęgniarka,nurse,медсестра,медсестра
|
||||
pieniądze,money,деньги,гроші
|
||||
pieprz,pepper,перец,перець
|
||||
pies,dog,собака,собака
|
||||
piętnaście,fifteen,пятнадцать,п’ятнадцять
|
||||
piłka,ball,мяч,м’яч
|
||||
piosenka,song,песня,пісня
|
||||
pisać,to write,писать,писати
|
||||
pisanie,writing,написание,написання
|
||||
pisarz,writer,писатель,письменник
|
||||
płacić,to pay,платить,платити
|
||||
plan,plan,план,план
|
||||
płaszcz,coat,пальто,пальто
|
||||
plaża,beach,пляж,пляж
|
||||
plecy,back,спина,спина
|
||||
pływanie,swimming,плавание,плавання
|
||||
pociąg,train,поезд,поїзд
|
||||
początek,beginning,начало,початок
|
||||
pod,under,под,під
|
||||
podczas,during,во время,під час
|
||||
podekscytowany,excited,взволнованный,схвильований
|
||||
podłoga,floor,пол,підлога
|
||||
podobny,similar,похожий,схожий
|
||||
podróż,trip / journey,поездка / путешествие,подорож
|
||||
podróżować,to travel,путешествовать,подорожувати
|
||||
podwórko,yard,двор,подвір’я
|
||||
pogoda,weather,погода,погода
|
||||
pokazać,to show,показать,показати
|
||||
pokój,room,комната,кімната
|
||||
policja,police,полиция,поліція
|
||||
policjant,police officer,полицейский,поліцейський
|
||||
północ,midnight / north,полночь / север,опівніч / північ
|
||||
połowa,half,половина,половина
|
||||
położyć,to put / to lay,положить,покласти
|
||||
południe,noon / south,полдень / юг,полудень / південь
|
||||
pomagać,to help,помогать,допомагати
|
||||
pomarańcza,orange,апельсин,апельсин
|
||||
pomidor,tomato,помидор,помідор
|
||||
pomiędzy,between,между,між
|
||||
pomysł,idea,идея,ідея
|
||||
poniedziałek,Monday,понедельник,понеділок
|
||||
popołudnie,afternoon,послеобеденное время,післяобідній час
|
||||
poprawić,to improve / to correct,улучшить / исправить,покращити / виправити
|
||||
poprawny,correct,правильный,правильний
|
||||
popularny,popular,популярный,популярний
|
||||
porównać,to compare,сравнить,порівняти
|
||||
posiłek,meal,приём пищи,прийом їжі
|
||||
potrzebować,to need,нуждаться / нуждаться в,потребувати
|
||||
powiedzieć,to say / to tell,сказать,сказати
|
||||
powietrze,air,воздух,повітря
|
||||
powinien,should,должен,повинен
|
||||
powód,reason,причина,причина
|
||||
powszechny,common,распространённый,поширений
|
||||
powtórzyć,to repeat,повторить,повторити
|
||||
później,later,позже,пізніше
|
||||
późno,late,поздно,пізно
|
||||
pozwolić,to allow / to let,позволить / разрешить,дозволити
|
||||
pozytywny,positive,положительный,позитивний
|
||||
praca,work / job,работа,робота
|
||||
pracować,to work,работать,працювати
|
||||
pracownik,employee / worker,работник / сотрудник,працівник / співробітник
|
||||
praktyka,practice,практика,практика
|
||||
prawdopodobnie,probably,вероятно,ймовірно
|
||||
prawdziwy,true / real,настоящий / истинный,справжній
|
||||
prawo,law / right,закон / право,закон / право
|
||||
preferować,to prefer,предпочитать,надавати перевагу
|
||||
prezent,gift / present,подарок,подарунок
|
||||
problem,problem,проблема,проблема
|
||||
próbować,to try,пытаться / пробовать,намагатися / пробувати
|
||||
produkt,product,продукт / товар,продукт / товар
|
||||
program,program,программа,програма
|
||||
projekt,project,проект,проєкт
|
||||
proszę,please / you're welcome,пожалуйста / не за что,будь ласка / нема за що
|
||||
prowadzić,to drive / to lead,водить / руководить,керувати / вести
|
||||
prysznic,shower,душ,душ
|
||||
przeciwny,opposite,противоположный,протилежний
|
||||
przed,before / in front of,перед / до,перед / до
|
||||
przedmiot,object / subject,предмет,предмет
|
||||
przedstawić,to introduce / to present,представить,представити
|
||||
przestraszony,scared / frightened,испуганный,наляканий
|
||||
przestrzeń,space,пространство,простір
|
||||
przesunąć,to move / to shift,сдвинуть / передвинуть,посунути / пересунути
|
||||
przeszłość,past,прошлое,минуле
|
||||
przez,through / across,через / сквозь,через / крізь
|
||||
przód,front,передняя часть / перед,передня частина / перед
|
||||
przybyć,to arrive,прибыть / приехать,прибути / приїхати
|
||||
przygotować,to prepare,подготовить,підготувати
|
||||
przyjaciel,friend,друг,друг
|
||||
przyjazny,friendly,дружелюбный,дружній
|
||||
przyjść,to come,прийти,прийти
|
||||
przykład,example,пример,приклад
|
||||
przynieść,to bring,принести,принести
|
||||
przyszłość,future,будущее,майбутнє
|
||||
ptak,bird,птица,птах
|
||||
pudełko,box,коробка,коробка
|
||||
punkt,point,точка / пункт,точка / пункт
|
||||
pyszny,delicious,вкусный,смачний
|
||||
pytać,to ask,спрашивать,запитувати
|
||||
pytanie,question,вопрос,питання
|
||||
rachunek,bill / check,счёт,рахунок
|
||||
rada,advice,совет,порада
|
||||
radio,radio,радио,радіо
|
||||
rano,in the morning,утром,вранці
|
||||
raport,report,отчёт,звіт
|
||||
raz,once / one time,раз / один раз,раз / один раз
|
||||
razem,together,вместе,разом
|
||||
ręka,hand / arm,рука,рука
|
||||
restauracja,restaurant,ресторан,ресторан
|
||||
robić,to do / to make,делать,робити
|
||||
rodzina,family,семья,сім’я
|
||||
rok,year,год,рік
|
||||
rolnik,farmer,фермер,фермер
|
||||
roślina,plant,растение,рослина
|
||||
rosnąć,to grow,расти,рости
|
||||
rower,bicycle / bike,велосипед,велосипед
|
||||
rozmawiać,to talk,разговаривать,розмовляти
|
||||
rozmowa,conversation,разговор,розмова
|
||||
różnica,difference,разница,різниця
|
||||
różowy,pink,розовый,рожевий
|
||||
rozumieć,to understand,понимать,розуміти
|
||||
ruch,movement / traffic,движение / дорожное движение,рух / дорожній рух
|
||||
rutyna,routine,распорядок / рутина,розпорядок / рутина
|
||||
ryba,fish,рыба,риба
|
||||
rynek,market,рынок,ринок
|
||||
rysować,to draw,рисовать,малювати
|
||||
ryż,rice,рис,рис
|
||||
rzecz,thing,вещь,річ
|
||||
rzeka,river,река,річка
|
||||
sałatka,salad,салат,салат
|
||||
samochód,car,автомобиль / машина,автомобіль / машина
|
||||
samolot,airplane,самолёт,літак
|
||||
sąsiad,neighbor,сосед,сусід
|
||||
sąsiedztwo,neighborhood,соседство,сусідство
|
||||
ściana,wall,стена,стіна
|
||||
sekcja,section,раздел / секция,розділ / секція
|
||||
sekunda,second,секунда,секунда
|
||||
ser,cheese,сыр,сир
|
||||
siebie,oneself,себя,себе
|
||||
siedem,seven,семь,сім
|
||||
siedemdziesiąt,seventy,семьдесят,сімдесят
|
||||
siedemnaście,seventeen,семнадцать,сімнадцять
|
||||
siedzieć,to sit,сидеть,сидіти
|
||||
sierpień,August,август,серпень
|
||||
silny,strong,сильный,сильний
|
||||
siłownia,gym,спортзал,спортзал
|
||||
siostra,sister,сестра,сестра
|
||||
sklep,shop / store,магазин,магазин
|
||||
skończyć,to finish,закончить,закінчити
|
||||
skręcić,to turn,повернуть / свернуть,повернути / звернути
|
||||
sławny,famous,знаменитый,відомий
|
||||
śledzić,to follow / to track,следить / отслеживать,стежити / відстежувати
|
||||
słoń,elephant,слон,слон
|
||||
słońce,sun,солнце,сонце
|
||||
słownik,dictionary,словарь,словник
|
||||
słowo,word,слово,слово
|
||||
słuchać,to listen,слушать,слухати
|
||||
słyszeć,to hear,слышать,чути
|
||||
śmietana,sour cream,сметана,сметана
|
||||
smutny,sad,грустный,сумний
|
||||
śniadanie,breakfast,завтрак,сніданок
|
||||
śnieg,snow,снег,сніг
|
||||
sobota,Saturday,суббота,субота
|
||||
sok,juice,сок,сік
|
||||
sól,salt,соль,сіль
|
||||
spać,to sleep,спать,спати
|
||||
specjalny,special,особенный / специальный,особливий / спеціальний
|
||||
śpiewak,singer,певец,співак
|
||||
spódnica,skirt,юбка,спідниця
|
||||
spodnie,trousers / pants,брюки,штани
|
||||
sport,sport,спорт,спорт
|
||||
sposób,way / method,способ / метод,спосіб / метод
|
||||
spotkać,to meet,встретить / встретиться,зустріти / зустрітися
|
||||
spotkanie,meeting,встреча,зустріч
|
||||
sprawdzić,to check,проверить,перевірити
|
||||
sprzedać,to sell,продать,продати
|
||||
środa,Wednesday,среда,середа
|
||||
stać,to stand / to cost,стоять / стоить,стояти / коштувати
|
||||
stacja,station,станция,станція
|
||||
stary,old,старый,старий
|
||||
sto,hundred,сто,сто
|
||||
stół,table,стол,стіл
|
||||
stolica,capital,столица,столиця
|
||||
stopa,foot,ступня / стопа,стопа / ступня
|
||||
straszny,scary / terrible,страшный / ужасный,страшний / жахливий
|
||||
strona,side / page,сторона / страница,сторона / сторінка
|
||||
student,student,студент,студент
|
||||
styczeń,January,январь,січень
|
||||
styl,style,стиль,стиль
|
||||
sukces,success,успех,успіх
|
||||
sukienka,dress,платье,сукня
|
||||
supermarket,supermarket,супермаркет,супермаркет
|
||||
sweter,sweater,свитер,светр
|
||||
świat,world,мир,світ
|
||||
światło,light,свет,світло
|
||||
świetny,great / excellent,отличный,чудовий
|
||||
świnia,pig,свинья,свиня
|
||||
syn,son,сын,син
|
||||
sypialnia,bedroom,спальня,спальня
|
||||
sytuacja,situation,ситуация,ситуація
|
||||
szary,gray / grey,серый,сірий
|
||||
szczegół,detail,деталь,деталь
|
||||
szczęśliwy,happy,счастливый,щасливий
|
||||
sześć,six,шесть,шість
|
||||
sześćdziesiąt,sixty,шестьдесят,шістдесят
|
||||
szesnaście,sixteen,шестнадцать,шістнадцять
|
||||
szkło,glass,стекло,скло
|
||||
szkoła,school,школа,школа
|
||||
szpital,hospital,больница,лікарня
|
||||
sztuka,art,искусство,мистецтво
|
||||
szybki,fast / quick,быстрый,швидкий
|
||||
szybko,quickly / fast,быстро,швидко
|
||||
tak,yes,да,так
|
||||
taksówka,taxi,такси,таксі
|
||||
talerz,plate,тарелка,тарілка
|
||||
tam,there,там,там
|
||||
tancerz,dancer,танцор,танцівник
|
||||
tańczyć,to dance,танцевать,танцювати
|
||||
tani,cheap,дешёвый,дешевий
|
||||
taniec,dance,танец,танець
|
||||
tata,dad / father,папа / отец,тато / батько
|
||||
teatr,theater / theatre,театр,театр
|
||||
tekst,text,текст,текст
|
||||
telefon,telephone / phone,телефон,телефон
|
||||
telewizor,TV / television,телевизор,телевізор
|
||||
temat,topic / subject,тема,тема
|
||||
ten,this / this one,этот,цей
|
||||
tenis,tennis,теннис,теніс
|
||||
teraz,now,сейчас,зараз
|
||||
test,test,тест,тест
|
||||
też,also / too,тоже / также,теж / також
|
||||
to,this / it,это,це
|
||||
toaleta,toilet / restroom,туалет,туалет
|
||||
torba,bag,сумка,сумка
|
||||
trudny,difficult / hard,трудный,складний
|
||||
trzeci,third,третий,третій
|
||||
trzy,three,три,три
|
||||
trzydzieści,thirty,тридцать,тридцять
|
||||
trzynaście,thirteen,тринадцать,тринадцять
|
||||
turysta,tourist,турист,турист
|
||||
tutaj,here,здесь,тут
|
||||
twarz,face,лицо,обличчя
|
||||
twój,your,твой,твій
|
||||
tworzyć,to create,создавать,створювати
|
||||
ty,you,ты,ти
|
||||
tydzień,week,неделя,тиждень
|
||||
tylko,only,только,тільки
|
||||
typ,type / kind,тип / вид,тип / вид
|
||||
tysiąc,thousand,тысяча,тисяча
|
||||
tytuł,title,название / заголовок,назва / заголовок
|
||||
ubrania,clothes,одежда,одяг
|
||||
ucho,ear,ухо,вухо
|
||||
uczucie,feeling,чувство,почуття
|
||||
uczyć,to learn / to teach,учить / учиться,вчити / вчитися
|
||||
ukończyć,to complete / to finish,закончить / завершить,закінчити
|
||||
ulica,street,улица,вулиця
|
||||
ulubiony,favorite,любимый,улюблений
|
||||
umiejętność,skill / ability,навык / способность,навичка / здатність
|
||||
umrzeć,to die,умереть,померти
|
||||
uniwersytet,university,университет,університет
|
||||
upaść,to fall,упасть,впасти
|
||||
urodziny,birthday,день рождения,день народження
|
||||
użyteczny,useful,полезный,корисний
|
||||
używać,to use,использовать,використовувати
|
||||
wakacje,holiday / vacation,каникулы / отпуск,канікули / відпустка
|
||||
warzywo,vegetable,овощ,овоч
|
||||
wąż,snake,змея,змія
|
||||
ważny,important,важный,важливий
|
||||
wciąż,still,всё ещё,досі / все ще
|
||||
wcześnie,early,рано,рано
|
||||
wczoraj,yesterday,вчера,вчора
|
||||
weekend,weekend,выходные,вихідні
|
||||
wiadomość,message / news,сообщение / новость,повідомлення / новина
|
||||
wiadomości,news,новости,новини
|
||||
wideo,video,видео,відео
|
||||
widzieć,to see,видеть,бачити
|
||||
więc,so / therefore,так что / поэтому,тому / отже
|
||||
więcej,more,больше,більше
|
||||
wieczór,evening,вечер,вечір
|
||||
wiedzieć,to know,знать,знати
|
||||
wiek,age,возраст,вік
|
||||
wiele,many / much,много,багато
|
||||
wierzyć,to believe,верить,вірити
|
||||
wino,wine,вино,вино
|
||||
wiosna,spring,весна,весна
|
||||
wkrótce,soon,скоро,скоро
|
||||
własny,own,собственный,власний
|
||||
włosy,hair,волосы,волосся
|
||||
woda,water,вода,вода
|
||||
wokoło,around,вокруг,навколо
|
||||
wolny,free / slow,свободный / медленный,вільний / повільний
|
||||
wrócić,to return / to come back,вернуться,повернутися
|
||||
wrzesień,September,сентябрь,вересень
|
||||
wschód,east / sunrise,восток / восход,схід / схід сонця
|
||||
wszyscy,everyone / everybody,все / все люди,усі / всі
|
||||
wszystko,everything,всё,все
|
||||
wtedy,then,тогда,тоді
|
||||
wtorek,Tuesday,вторник,вівторок
|
||||
wujek,uncle,дядя,дядько
|
||||
wybierać,to choose,выбирать,вибирати
|
||||
wydarzenie,event,событие,подія
|
||||
wydawać,to spend / to seem,тратить / казаться,витрачати / здаватися
|
||||
wygrać,to win,выиграть,виграти
|
||||
wyjaśnić,to explain,объяснить,пояснити
|
||||
wykres,chart / graph,график / диаграмма,графік / діаграма
|
||||
wynik,result,результат,результат
|
||||
wysłać,to send,отправить,надіслати
|
||||
wysoki,tall / high,высокий,високий
|
||||
wyspa,island,остров,острів
|
||||
ząb,tooth,зуб,зуб
|
||||
żaba,frog,лягушка,жаба
|
||||
zabawa,fun / party,веселье / вечеринка,розвага / вечірка
|
||||
zabawny,funny,смешной,смішний
|
||||
zachód,west / sunset,запад / закат,захід / захід сонця
|
||||
zacząć,to start / to begin,начать,почати
|
||||
zaczynać,to start / to begin,начинать,починати
|
||||
zadzwonić,to call,позвонить,зателефонувати
|
||||
zainteresowanie,interest,интерес,інтерес
|
||||
zainteresowany,interested,заинтересованный,зацікавлений
|
||||
zajęty,busy,занятый,зайнятий
|
||||
zakupy,shopping,покупки,покупки
|
||||
zamknąć,to close,закрыть,закрити
|
||||
zamówienie,order,заказ,замовлення
|
||||
zapomnieć,to forget,забыть,забути
|
||||
zasada,rule,правило,правило
|
||||
zatrzymać,to stop / to hold,остановить / удержать,зупинити / утримати
|
||||
zawierać,to contain,содержать,містити
|
||||
zawsze,always,всегда,завжди
|
||||
zdanie,sentence,предложение,речення
|
||||
zdjęcie,photo / picture,фотография / фото,фотографія / фото
|
||||
zdrowie,health,здоровье,здоров’я
|
||||
zdrowy,healthy,здоровый,здоровий
|
||||
zegar,clock,часы,годинник
|
||||
zespół,team / group,команда / группа,команда / група
|
||||
zgadywać,to guess,угадывать,вгадувати
|
||||
zgubić,to lose,потерять,загубити
|
||||
zielony,green,зелёный,зелений
|
||||
ziemia,earth / soil,земля,земля
|
||||
ziemniak,potato,картофель / картошка,картопля
|
||||
zima,winter,зима,зима
|
||||
zimny,cold,холодный,холодний
|
||||
złamać,to break,сломать,зламати
|
||||
zły,bad / angry,плохой / злой,поганий / злий
|
||||
zmęczony,tired,уставший,втомлений
|
||||
zmiana,change,изменение / перемена,зміна
|
||||
znaczenie,meaning,значение,значення
|
||||
znaleźć,to find,найти,знайти
|
||||
znowu,again,снова,знову
|
||||
znudzony,bored,скучающий,знудьгований
|
||||
żółty,yellow,жёлтый,жовтий
|
||||
żona,wife,жена,дружина
|
||||
żonaty,married,женатый,одружений
|
||||
zostać,to stay / to become,остаться / стать,залишитися / стати
|
||||
zupa,soup,суп,суп
|
||||
zwierzę,animal,животное,тварина
|
||||
zwykle,usually,обычно,зазвичай
|
||||
żyć,to live,жить,жити
|
||||
życie,life,жизнь,життя
|
||||
|
+843
@@ -0,0 +1,843 @@
|
||||
polish,english,russian,ukrainian
|
||||
adwokat,lawyer,адвокат,адвокат
|
||||
akceptuję,I accept,я принимаю,я приймаю
|
||||
aktywny,active,активный,активний
|
||||
albo,or,или,або
|
||||
alternatywa,alternative,альтернатива,альтернатива
|
||||
analizować,to analyze,анализировать,аналізувати
|
||||
aplikacja,application,приложение,додаток
|
||||
aplikować,to apply,подавать заявление,подавати заяву
|
||||
architekt,architect,архитектор,архітектор
|
||||
architektura,architecture,архитектура,архітектура
|
||||
armia,army,армия,армія
|
||||
asystent,assistant,ассистент,асистент
|
||||
atrakcyjny,attractive,привлекательный,привабливий
|
||||
autor,author,автор,автор
|
||||
aż,as much as / until,аж / пока,аж / поки
|
||||
badacz,researcher,исследователь,дослідник
|
||||
badania,research,исследования,дослідження
|
||||
badanie,examination / study,обследование / исследование,обстеження / дослідження
|
||||
bezpieczny,safe,безопасный,безпечний
|
||||
bezpośredni,direct,прямой,прямий
|
||||
biały,white,белый,білий
|
||||
bić,to beat,бить,бити
|
||||
bieg,run / race,бег / забег,біг / забіг
|
||||
biegacz,runner,бегун,бігун
|
||||
bieganie,running,бег,біг
|
||||
biologia,biology,биология,біологія
|
||||
biznesmen,businessman,бизнесмен,бізнесмен
|
||||
biżuteria,jewelry,ювелирные изделия,ювелірні вироби
|
||||
błąd,mistake,ошибка,помилка
|
||||
blisko,near,близко,близько
|
||||
bliźniak,twin,близнец,близнюк
|
||||
blok,block,блок,блок
|
||||
błyskotliwy,brilliant,блестящий,блискучий
|
||||
bóg,god,бог,бог
|
||||
bohater,hero,герой,герой
|
||||
bok,side,бок,бік
|
||||
ból,pain,боль,біль
|
||||
brakujący,missing,отсутствующий,відсутній
|
||||
brama,gate,ворота,ворота
|
||||
brązowy,brown,коричневый,коричневий
|
||||
brzmieć,to sound,звучать,звучати
|
||||
burmistrz,mayor,мэр,мер
|
||||
być,to be,быть,бути
|
||||
całkowicie,completely,полностью,повністю
|
||||
cały,whole,целый,цілий
|
||||
cecha,feature,черта,риса
|
||||
cel,goal,цель,мета
|
||||
cela,cell,камера,камера
|
||||
celebryta,celebrity,знаменитость,знаменитість
|
||||
centrum,center,центр,центр
|
||||
chemia,chemistry,химия,хімія
|
||||
chmura,cloud,облако,хмара
|
||||
chociaż,although,хотя,хоча
|
||||
choroba,illness,болезнь,хвороба
|
||||
chory,sick,больной,хворий
|
||||
chronić,to protect,защищать,захищати
|
||||
ciągnąć,to pull,тянуть,тягнути
|
||||
ciastko,cookie,печенье,печиво
|
||||
cicho,quietly,тихо,тихо
|
||||
ciemność,darkness,темнота,темрява
|
||||
cienki,thin,тонкий,тонкий
|
||||
ciepło,warmth,тепло,тепло
|
||||
ciężki,heavy,тяжёлый,важкий
|
||||
ciężko,heavily,тяжело,важко
|
||||
co,what,что,що
|
||||
codzienny,daily,ежедневный,щоденний
|
||||
cukierki,candies,конфеты,цукерки
|
||||
ćwiczyć,to exercise,тренироваться,тренуватися
|
||||
cyfrowy,digital,цифровой,цифровий
|
||||
cykl,cycle,цикл,цикл
|
||||
cytryna,lemon,лимон,лимон
|
||||
czarny,black,чёрный,чорний
|
||||
czekanie,waiting,ожидание,очікування
|
||||
czerwony,red,красный,червоний
|
||||
człowiek,person,человек,людина
|
||||
czubek,tip,кончик,кінчик
|
||||
czyj,whose,чей,чий
|
||||
czynnik,factor,фактор,фактор
|
||||
czyścić,to clean,чистить,чистити
|
||||
czysty,clean,чистый,чистий
|
||||
dach,roof,крыша,дах
|
||||
dalej,further,дальше,далі
|
||||
daleko,far,далеко,далеко
|
||||
dane,data,данные,дані
|
||||
darmowy,free,бесплатный,безкоштовний
|
||||
decyzja,decision,решение,рішення
|
||||
dentysta,dentist,стоматолог,стоматолог
|
||||
departament,department,департамент,департамент
|
||||
deser,dessert,десерт,десерт
|
||||
deska,board,доска,дошка
|
||||
detektyw,detective,детектив,детектив
|
||||
długo,long,долго,довго
|
||||
dmuchać,to blow,дуть,дмухати
|
||||
dno,bottom,дно,дно
|
||||
dobroczynność,charity,благотворительность,благодійність
|
||||
dobry,good,хороший,добрий
|
||||
dobrze,well,хорошо,добре
|
||||
dokładnie,exactly,точно,точно
|
||||
dokładny,precise,точный,точний
|
||||
dokument,document,документ,документ
|
||||
dolina,valley,долина,долина
|
||||
dom,home,дом,дім
|
||||
dorosły,adult,взрослый,дорослий
|
||||
dość,enough,достаточно,достатньо
|
||||
doskonały,perfect,отличный,відмінний
|
||||
dostępny,available,доступный,доступний
|
||||
doświadczenie,experience,опыт,досвід
|
||||
dotknąć,to touch,тронуть,торкнутися
|
||||
dowcip,joke,шутка,жарт
|
||||
dowód,proof,доказательство,доказ
|
||||
drama,drama,драма,драма
|
||||
drewniany,wooden,деревянный,дерев'яний
|
||||
drewno,wood,древесина,деревина
|
||||
drugi,second,второй,другий
|
||||
drukarka,printer,принтер,принтер
|
||||
drukować,to print,печатать,друкувати
|
||||
dużo,many,много,багато
|
||||
dyrektor,director,директор,директор
|
||||
dyskusja,discussion,дискуссия,дискусія
|
||||
dywan,carpet,ковёр,килим
|
||||
dżem,jam,джем,джем
|
||||
działać,to work,действовать,діяти
|
||||
dziecko,child,ребёнок,дитина
|
||||
dziennik,diary / journal,дневник / журнал,щоденник / журнал
|
||||
dziennikarz,journalist,журналист,журналіст
|
||||
dziki,wild,дикий,дикий
|
||||
dzisiaj,today,сегодня,сьогодні
|
||||
dziura,hole,дыра,діра
|
||||
dziwny,strange,странный,дивний
|
||||
dzwonić,to call,звонить,телефонувати
|
||||
edukacja,education,образование,освіта
|
||||
efekt,effect,эффект,ефект
|
||||
ekran,screen,экран,екран
|
||||
ekspert,expert,эксперт,експерт
|
||||
eksperyment,experiment,эксперимент,експеримент
|
||||
ekstremalny,extreme,экстремальный,екстремальний
|
||||
elektroniczny,electronic,электронный,електронний
|
||||
elektryczność,electricity,электричество,електрика
|
||||
elektryczny,electric,электрический,електричний
|
||||
energia,energy,энергия,енергія
|
||||
esej,essay,эссе,есе
|
||||
fabryka,factory,фабрика,фабрика
|
||||
facet,guy,парень,хлопець
|
||||
farma,farm,ферма,ферма
|
||||
fasola,beans,фасоль,квасоля
|
||||
fikcja,fiction,вымысел,вигадка
|
||||
film,film,фильм,фільм
|
||||
finał,final,финал,фінал
|
||||
fioletowy,purple,фиолетовый,фіолетовий
|
||||
fizyczny,physical,физический,фізичний
|
||||
fizyka,physics,физика,фізика
|
||||
formalny,formal,формальный,формальний
|
||||
formować,to form,формировать,формувати
|
||||
fotografować,to photograph,фотографировать,фотографувати
|
||||
galeria,gallery,галерея,галерея
|
||||
garnitur,suit,костюм,костюм
|
||||
gaz,gas,газ,газ
|
||||
gdzie,where,где,де
|
||||
gdziekolwiek,anywhere,где угодно,де завгодно
|
||||
gdzieś,somewhere,где-то,десь
|
||||
głęboki,deep,глубокий,глибокий
|
||||
głos,voice,голос,голос
|
||||
głośno,loudly,громко,голосно
|
||||
głośny,loud,громкий,голосний
|
||||
głównie,mainly,в основном,в основному
|
||||
główny,main,главный,головний
|
||||
głupi,stupid,глупый,дурний
|
||||
golf,golf,гольф,гольф
|
||||
gorączka,fever,лихорадка,гарячка
|
||||
gorszy,worse,хуже,гірше
|
||||
gość,guest,гость,гість
|
||||
gotować,to cook,готовить,готувати
|
||||
gotówka,cash,наличные,готівка
|
||||
gra,game,игра,гра
|
||||
grosz,grosz / penny,грош / копейка,гріш / копійка
|
||||
gruby,thick,толстый,товстий
|
||||
grypa,flu,грипп,грип
|
||||
grzeczny,polite,вежливый,ввічливий
|
||||
guzik,button,пуговица,гудзик
|
||||
gwiazda,star,звезда,зірка
|
||||
hałas,noise,шум,шум
|
||||
hałaśliwy,noisy,шумный,шумний
|
||||
harmonogram,schedule,расписание,розклад
|
||||
hokej,hockey,хоккей,хокей
|
||||
idealny,perfect,идеальный,ідеальний
|
||||
ilość,quantity,количество,кількість
|
||||
inaczej,differently,иначе,інакше
|
||||
inny,other,другой,інший
|
||||
instrukcja,instruction,инструкция,інструкція
|
||||
instruktor,instructor,инструктор,інструктор
|
||||
instrument,instrument,инструмент,інструмент
|
||||
inteligentny,intelligent,умный,розумний
|
||||
interesować,to interest,интересовать,цікавити
|
||||
inżynier,engineer,инженер,інженер
|
||||
istnieć,to exist,существовать,існувати
|
||||
jak,how,как,як
|
||||
jakość,quality,качество,якість
|
||||
jasny,bright,светлый,світлий
|
||||
jazda,driving / ride,езда,їзда
|
||||
jeden,one,один,один
|
||||
jednostka,unit,единица,одиниця
|
||||
jego,his,его,його
|
||||
jej,her,её,її
|
||||
jeszcze,still,ещё,ще
|
||||
jezioro,lake,озеро,озеро
|
||||
jutro,tomorrow,завтра,завтра
|
||||
już,already,уже,вже
|
||||
kamień,stone,камень,камінь
|
||||
kampus,campus,кампус,кампус
|
||||
katastrofa,disaster,катастрофа,катастрофа
|
||||
kawałek,piece,кусок,шматок
|
||||
każdy,every,каждый,кожен
|
||||
kiedy,when,когда,коли
|
||||
kierunek,direction,направление,напрямок
|
||||
kieszeń,pocket,карман,кишеня
|
||||
kilka,several,несколько,кілька
|
||||
kilometr,kilometer,километр,кілометр
|
||||
klasyczny,classic,классический,класичний
|
||||
klimat,climate,климат,клімат
|
||||
kłótnia,argument,ссора,сварка
|
||||
kluczowy,key,ключевой,ключовий
|
||||
kod,code,код,код
|
||||
kolano,knee,колено,коліно
|
||||
kolega,colleague,коллега,колега
|
||||
kolej,railway,железная дорога,залізниця
|
||||
koło,wheel,колесо,колесо
|
||||
kolumna,column,колонна,колона
|
||||
komedia,comedy,комедия,комедія
|
||||
komentarz,comment,комментарий,коментар
|
||||
kompletny,complete,полный,повний
|
||||
komunikować,to communicate,общаться,спілкуватися
|
||||
konferencja,conference,конференция,конференція
|
||||
koniec,end,конец,кінець
|
||||
konieczny,necessary,необходимый,необхідний
|
||||
konkurs,competition,конкурс,конкурс
|
||||
kontekst,context,контекст,контекст
|
||||
kontrolować,to control,контролировать,контролювати
|
||||
kontynent,continent,континент,континент
|
||||
kontynuować,to continue,продолжать,продовжувати
|
||||
kopiować,to copy,копировать,копіювати
|
||||
korzyść,benefit,польза,користь
|
||||
kość,bone,кость,кістка
|
||||
kościół,church,церковь,церква
|
||||
kostka,cube,кубик,кубик
|
||||
kosztować,to cost,стоить,коштувати
|
||||
kraść,to steal,красть,красти
|
||||
kreatywny,creative,творческий,творчий
|
||||
kręcony,curly,кудрявый,кучерявий
|
||||
kredyt,loan,кредит,кредит
|
||||
kreskówka,cartoon,мультфильм,мультфільм
|
||||
krew,blood,кровь,кров
|
||||
krok,step,шаг,крок
|
||||
król,king,король,король
|
||||
królowa,queen,королева,королева
|
||||
krzyczeć,to shout,кричать,кричати
|
||||
księżyc,moon,луна,місяць
|
||||
kształt,shape,форма,форма
|
||||
ktokolwiek,anyone,кто угодно,хто завгодно
|
||||
który,which,который,який
|
||||
kucharz,cook,повар,кухар
|
||||
kuchenka,stove,плита,плита
|
||||
kwadratowy,square,квадратный,квадратний
|
||||
laboratorium,laboratory,лаборатория,лабораторія
|
||||
lądować,to land,приземляться,приземлятися
|
||||
lampa,lamp,лампа,лампа
|
||||
las,forest,лес,ліс
|
||||
latanie,flying,полёт,політ
|
||||
łatwo,easily,легко,легко
|
||||
lek,medicine,лекарство,ліки
|
||||
lekarstwo,medicine,лекарство,ліки
|
||||
lekki,light,лёгкий,легкий
|
||||
leniwy,lazy,ленивый,лінивий
|
||||
lewy,left,левый,лівий
|
||||
liczba,number,число,число
|
||||
liczyć,to count,считать,рахувати
|
||||
lider,leader,лидер,лідер
|
||||
link,link,ссылка,посилання
|
||||
lodówka,fridge,холодильник,холодильник
|
||||
luka,gap,пробел,прогалина
|
||||
lustro,mirror,зеркало,дзеркало
|
||||
łyżka,spoon,ложка,ложка
|
||||
machnięcie,wave,взмах,змах
|
||||
malarz,painter,художник,художник
|
||||
mało,little,мало,мало
|
||||
malować,to paint,рисовать,малювати
|
||||
małpa,monkey,обезьяна,мавпа
|
||||
matematyka,mathematics,математика,математика
|
||||
materiał,material,материал,матеріал
|
||||
meble,furniture,мебель,меблі
|
||||
media,media,медиа,медіа
|
||||
medyczny,medical,медицинский,медичний
|
||||
menedżer,manager,менеджер,менеджер
|
||||
męski,male,мужской,чоловічий
|
||||
metal,metal,металл,метал
|
||||
metoda,method,метод,метод
|
||||
metro,subway,метро,метро
|
||||
mieć,to have,иметь,мати
|
||||
międzynarodowy,international,международный,міжнародний
|
||||
miejsce,place,место,місце
|
||||
miękki,soft,мягкий,м'який
|
||||
miliard,billion,миллиард,мільярд
|
||||
miłość,love,любовь,любов
|
||||
minąć,to pass,пройти,минути
|
||||
miniony,past,прошлый,минулий
|
||||
miska,bowl,миска,миска
|
||||
mniej,less,меньше,менше
|
||||
moda,fashion,мода,мода
|
||||
modelka,model,модель,модель
|
||||
mój,my,мой,мій
|
||||
mokry,wet,мокрый,мокрий
|
||||
morze,sea,море,море
|
||||
most,bridge,мост,міст
|
||||
motocykl,motorcycle,мотоцикл,мотоцикл
|
||||
mowa,speech,речь,мова
|
||||
mówca,speaker,оратор,оратор
|
||||
może,maybe,возможно,можливо
|
||||
mózg,brain,мозг,мозок
|
||||
możliwość,possibility,возможность,можливість
|
||||
mundur,uniform,униформа,уніформа
|
||||
muzyczny,musical,музыкальный,музичний
|
||||
muzyk,musician,музыкант,музикант
|
||||
mycie,washing,мытьё,миття
|
||||
mydło,soap,мыло,мило
|
||||
myśl,thought,мысль,думка
|
||||
myślenie,thinking,мышление,мислення
|
||||
nad,above,над,над
|
||||
nadzieja,hope,надежда,надія
|
||||
nagle,suddenly,вдруг,раптом
|
||||
nagranie,recording,запись,запис
|
||||
nagroda,prize,приз,приз
|
||||
nagrywać,to record,записывать,записувати
|
||||
najgorszy,worst,худший,найгірший
|
||||
najlepiej,best,лучше всего,найкраще
|
||||
najmniej,least,меньше всего,найменше
|
||||
najwięcej,most,больше всего,найбільше
|
||||
napój,drink,напиток,напій
|
||||
naprawić,to repair,починить,полагодити
|
||||
naprzód,forward,вперёд,вперед
|
||||
narciarstwo,skiing,лыжный спорт,лижний спорт
|
||||
narodowy,national,национальный,національний
|
||||
narodziny,birth,рождение,народження
|
||||
narzędzie,tool,инструмент,інструмент
|
||||
narzekać,to complain,жаловаться,скаржитися
|
||||
następny,next,следующий,наступний
|
||||
natura,nature,природа,природа
|
||||
natychmiast,immediately,немедленно,негайно
|
||||
nauczanie,teaching,преподавание,викладання
|
||||
nauka,learning,обучение,навчання
|
||||
nawyk,habit,привычка,звичка
|
||||
nazywać,to call,называть,називати
|
||||
nerwowy,nervous,нервный,нервовий
|
||||
niebezpieczeństwo,danger,опасность,небезпека
|
||||
niebieski,blue,синий,синій
|
||||
niebo,sky,небо,небо
|
||||
niedawno,recently,недавно,нещодавно
|
||||
niedawny,recent,недавний,нещодавній
|
||||
niedźwiedź,bear,медведь,ведмідь
|
||||
nieformalny,informal,неформальный,неформальний
|
||||
niegrzeczny,impolite,невежливый,неввічливий
|
||||
niektóre,some,некоторые,деякі
|
||||
niemożliwy,impossible,невозможный,неможливий
|
||||
niespodzianka,surprise,сюрприз,сюрприз
|
||||
niestety,unfortunately,к сожалению,на жаль
|
||||
nieszczęśliwy,unhappy,несчастный,нещасливий
|
||||
nietypowy,unusual,необычный,незвичайний
|
||||
niewiarygodny,incredible,невероятный,неймовірний
|
||||
niezależny,independent,независимый,незалежний
|
||||
niezwykle,extremely,чрезвычайно,надзвичайно
|
||||
nigdzie,nowhere,нигде,ніде
|
||||
niski,low,низкий,низький
|
||||
normalnie,normally,обычно,зазвичай
|
||||
normalny,normal,нормальный,нормальний
|
||||
nóż,knife,нож,ніж
|
||||
obietnica,promise,обещание,обіцянка
|
||||
oblać,to fail,провалить,провалити
|
||||
obóz,camp,лагерь,табір
|
||||
obraz,picture,картина,картина
|
||||
oczekiwać,to expect,ожидать,очікувати
|
||||
od,from,от,від
|
||||
oddzielny,separate,отдельный,окремий
|
||||
odkryć,to discover,обнаружить,виявити
|
||||
odkrycie,discovery,открытие,відкриття
|
||||
odległość,distance,расстояние,відстань
|
||||
odmówić,to refuse,отказать,відмовити
|
||||
odnosić,to relate,относиться,ставитися
|
||||
odpoczynek,rest,отдых,відпочинок
|
||||
odpowiedź,answer,ответ,відповідь
|
||||
odpowiedzieć,to answer,ответить,відповісти
|
||||
odzież,clothing,одежда,одяг
|
||||
oferować,to offer,предлагать,пропонувати
|
||||
ogólny,general,общий,загальний
|
||||
ogromny,huge,огромный,величезний
|
||||
okazja,opportunity,возможность,можливість
|
||||
około,about,около,близько
|
||||
okrągły,round,круглый,круглий
|
||||
okropny,terrible,ужасный,жахливий
|
||||
olej,oil,масло,олія
|
||||
oparty,based,основанный,заснований
|
||||
opcja,option,вариант,варіант
|
||||
oprócz,except,кроме,крім
|
||||
organizacja,organization,организация,організація
|
||||
organizować,to organize,организовывать,організовувати
|
||||
oryginalny,original,оригинальный,оригінальний
|
||||
orzech,nut,орех,горіх
|
||||
osiągnąć,to achieve,достичь,досягти
|
||||
osobowość,personality,личность,особистість
|
||||
ostatni,last,последний,останній
|
||||
ostrożnie,carefully,осторожно,обережно
|
||||
ostrożny,careful,осторожный,обережний
|
||||
otrzymać,to receive,получить,отримати
|
||||
otwarty,open,открытый,відкритий
|
||||
owad,insect,насекомое,комаха
|
||||
oznaczyć,to mark,обозначить,позначити
|
||||
pacjent,patient,пациент,пацієнт
|
||||
padać,to fall,падать,падати
|
||||
pająk,spider,паук,павук
|
||||
pakować,to pack,паковать,пакувати
|
||||
pałac,palace,дворец,палац
|
||||
palec,finger,палец,палець
|
||||
palenie,smoking,курение,куріння
|
||||
palić,to smoke,курить,курити
|
||||
pamięć,memory,память,пам'ять
|
||||
pan,sir,господин,пан
|
||||
pani,madam,госпожа,пані
|
||||
papieros,cigarette,сигарета,цигарка
|
||||
para,pair,пара,пара
|
||||
parkowanie,parking,парковка,паркування
|
||||
pasażer,passenger,пассажир,пасажир
|
||||
pasek,belt,ремень,ремінь
|
||||
pasować,to fit,подходить,підходити
|
||||
pchać,to push,толкать,штовхати
|
||||
pędzel,paintbrush,кисть,пензель
|
||||
pęk,bundle,связка,в'язка
|
||||
peron,platform,платформа,платформа
|
||||
pewien,certain,определённый,певний
|
||||
piec,to bake,печь,пекти
|
||||
pierścionek,ring,кольцо,каблучка
|
||||
pierwszy,first,первый,перший
|
||||
pilot,remote,пульт,пульт
|
||||
płaca,wage,зарплата,зарплата
|
||||
płakać,to cry,плакать,плакати
|
||||
plakat,poster,плакат,плакат
|
||||
planeta,planet,планета,планета
|
||||
planować,to plan,планировать,планувати
|
||||
płaski,flat,плоский,плаский
|
||||
plastik,plastic,пластик,пластик
|
||||
pobrać,to download,скачать,завантажити
|
||||
pobyt,stay,пребывание,перебування
|
||||
początek,beginning,начало,початок
|
||||
poczta,post office,почта,пошта
|
||||
pod,under,под,під
|
||||
podczas,during,во время,під час
|
||||
podnieść,to lift,поднять,підняти
|
||||
podróż,journey,путешествие,подорож
|
||||
podróżny,traveler,путешественник,мандрівник
|
||||
podwójny,double,двойной,подвійний
|
||||
pojedynczy,single,одиночный,одиничний
|
||||
pokaz,show,показ,показ
|
||||
pokój,room,комната,кімната
|
||||
połączony,connected,соединённый,з'єднаний
|
||||
połączyć,to connect,соединить,з'єднати
|
||||
pole,field,поле,поле
|
||||
polecać,to recommend,рекомендовать,рекомендувати
|
||||
północ,north,север,північ
|
||||
połowa,half,половина,половина
|
||||
południowy,southern,южный,південний
|
||||
pomarańczowy,orange,оранжевый,помаранчевий
|
||||
pomiędzy,between,между,між
|
||||
pomoc,help,помощь,допомога
|
||||
pomocny,helpful,полезный,корисний
|
||||
poprawić,to improve,улучшить,покращити
|
||||
poprawnie,correctly,правильно,правильно
|
||||
populacja,population,население,населення
|
||||
posiadać,to own,владеть,володіти
|
||||
post,post,пост,пост
|
||||
postać,character,персонаж,персонаж
|
||||
postęp,progress,прогресс,прогрес
|
||||
potrzeba,need,потребность,потреба
|
||||
poważny,serious,серьёзный,серйозний
|
||||
powieść,novel,роман,роман
|
||||
powitać,to welcome,приветствовать,вітати
|
||||
powrót,return,возвращение,повернення
|
||||
poziom,level,уровень,рівень
|
||||
później,later,позже,пізніше
|
||||
pozwalać,to allow,позволять,дозволяти
|
||||
pozwolenie,permission,разрешение,дозвіл
|
||||
pozycja,position,позиция,позиція
|
||||
pożyczyć,to borrow,занять,позичити
|
||||
praca,work,работа,робота
|
||||
pracodawca,employer,работодатель,роботодавець
|
||||
pracownik,employee,работник,працівник
|
||||
pracujący,working,работающий,працюючий
|
||||
pranie,laundry,стирка,прання
|
||||
prawdopodobny,probable,вероятный,ймовірний
|
||||
prawidłowy,correct,правильный,правильний
|
||||
prawie,almost,почти,майже
|
||||
prawo,law,закон,закон
|
||||
prędkość,speed,скорость,швидкість
|
||||
prezent,gift,подарок,подарунок
|
||||
prezentować,to present,представлять,представляти
|
||||
prezydent,president,президент,президент
|
||||
problem,problem,проблема,проблема
|
||||
procent,percent,процент,відсоток
|
||||
proces,process,процесс,процес
|
||||
produkować,to produce,производить,виробляти
|
||||
profesjonalny,professional,профессиональный,професійний
|
||||
profesor,professor,профессор,професор
|
||||
profil,profile,профиль,профіль
|
||||
projektant,designer,дизайнер,дизайнер
|
||||
projektować,to design,проектировать,проєктувати
|
||||
propozycja,proposal,предложение,пропозиція
|
||||
prośba,request,просьба,прохання
|
||||
prosto,straight,прямо,прямо
|
||||
prosty,simple,простой,простий
|
||||
proszę,please,пожалуйста,будь ласка
|
||||
prowadzić,to drive,водить,керувати
|
||||
przechodzić,to cross,переходить,переходити
|
||||
przeciw,against,против,проти
|
||||
przeciwieństwo,opposite,противоположность,протилежність
|
||||
przed,before,перед,перед
|
||||
przedni,front,передний,передній
|
||||
przedstawienie,performance,представление,вистава
|
||||
przejażdżka,ride,поездка,поїздка
|
||||
przemysł,industry,промышленность,промисловість
|
||||
przepis,recipe,рецепт,рецепт
|
||||
przerwa,break,перерыв,перерва
|
||||
prześcieradło,sheet,простыня,простирадло
|
||||
przestępca,criminal,преступник,злочинець
|
||||
przestępstwo,crime,преступление,злочин
|
||||
przestraszony,scared,испуганный,наляканий
|
||||
przewidzieć,to predict,предвидеть,передбачити
|
||||
przez,through,через,через
|
||||
przeziębienie,cold,простуда,застуда
|
||||
przyczyna,cause,причина,причина
|
||||
przygoda,adventure,приключение,пригода
|
||||
przykryć,to cover,накрыть,накрити
|
||||
przypadek,case,случай,випадок
|
||||
przystanek,stop,остановка,зупинка
|
||||
przyszłość,future,будущее,майбутнє
|
||||
publiczność,audience,аудитория,аудиторія
|
||||
publikować,to publish,публиковать,публікувати
|
||||
pukać,to knock,стучать,стукати
|
||||
pusty,empty,пустой,порожній
|
||||
pustynia,desert,пустыня,пустеля
|
||||
pytanie,question,вопрос,питання
|
||||
raczej,rather,скорее,скоріше
|
||||
radośnie,joyfully,радостно,радісно
|
||||
raportować,to report,отчитываться,звітувати
|
||||
reagować,to react,реагировать,реагувати
|
||||
recykling,recycling,переработка,переробка
|
||||
ręcznik,towel,полотенце,рушник
|
||||
region,region,регион,регіон
|
||||
regularny,regular,регулярный,регулярний
|
||||
reklama,advertisement,реклама,реклама
|
||||
reklamować,to advertise,рекламировать,рекламувати
|
||||
reporter,reporter,репортёр,репортер
|
||||
robić,to do,делать,робити
|
||||
rodzaj,type,тип,тип
|
||||
rodzina,family,семья,сім'я
|
||||
róg,corner,угол,кут
|
||||
rola,role,роль,роль
|
||||
rolnictwo,agriculture,сельское хозяйство,сільське господарство
|
||||
rozdawać,to distribute,раздавать,роздавати
|
||||
rozmawiać,to talk,разговаривать,розмовляти
|
||||
rozmiar,size,размер,розмір
|
||||
rozmowa,conversation,разговор,розмова
|
||||
różnorodność,diversity,разнообразие,різноманітність
|
||||
różowy,pink,розовый,рожевий
|
||||
rozpoznać,to recognize,распознать,розпізнати
|
||||
rozważać,to consider,рассматривать,розглядати
|
||||
rozwiązać,to solve,решить,вирішити
|
||||
rozwiązanie,solution,решение,рішення
|
||||
rozwiedziony,divorced,разведённый,розлучений
|
||||
rozwijać,to develop,развивать,розвивати
|
||||
ruch,movement,движение,рух
|
||||
rysunek,drawing,рисунок,малюнок
|
||||
rywalizować,to compete,соревноваться,змагатися
|
||||
rząd,government,правительство,уряд
|
||||
rzucać,to throw,бросать,кидати
|
||||
sala,room,зал,зал
|
||||
sam,alone,один,сам
|
||||
scena,scene,сцена,сцена
|
||||
schodek,step,ступенька,сходинка
|
||||
ścigać,to chase,преследовать,переслідувати
|
||||
sekret,secret,секрет,секрет
|
||||
sekretarz,secretary,секретарь,секретар
|
||||
sen,dream,сон,сон
|
||||
serce,heart,сердце,серце
|
||||
seria,series,серия,серія
|
||||
serwować,to serve,подавать,подавати
|
||||
siebie,oneself,себя,себе
|
||||
sieć,network,сеть,мережа
|
||||
siedzenie,seat,сиденье,сидіння
|
||||
silnik,engine,двигатель,двигун
|
||||
skakać,to jump,прыгать,стрибати
|
||||
skarpetka,sock,носок,шкарпетка
|
||||
skóra,skin,кожа,шкіра
|
||||
słaby,weak,слабый,слабкий
|
||||
słodki,sweet,сладкий,солодкий
|
||||
ślub,wedding,свадьба,весілля
|
||||
słuchacz,listener,слушатель,слухач
|
||||
smak,taste,вкус,смак
|
||||
śmiech,laughter,смех,сміх
|
||||
śmieci,trash,мусор,сміття
|
||||
smutnie,sadly,грустно,сумно
|
||||
śnić,to dream,сниться,снитися
|
||||
śnieg,snow,снег,сніг
|
||||
sos,sauce,соус,соус
|
||||
spacer,walk,прогулка,прогулянка
|
||||
specyficzny,specific,специфический,специфічний
|
||||
śpiący,sleepy,сонный,сонний
|
||||
śpiew,singing,пение,спів
|
||||
społeczeństwo,society,общество,суспільство
|
||||
społeczność,community,сообщество,спільнота
|
||||
społeczny,social,социальный,соціальний
|
||||
sportowiec,athlete,спортсмен,спортсмен
|
||||
sposób,way,способ,спосіб
|
||||
spóźniony,late,опоздавший,запізнілий
|
||||
sprawa,matter,дело,справа
|
||||
sprawdzenie,check,проверка,перевірка
|
||||
sprawiedliwy,fair,справедливый,справедливий
|
||||
sprzedaż,sale,продажа,продаж
|
||||
sprzęt,equipment,оборудование,обладнання
|
||||
srebro,silver,серебро,срібло
|
||||
średni,medium,средний,середній
|
||||
środkowy,middle,средний,середній
|
||||
środowisko,environment,окружающая среда,довкілля
|
||||
stan,state,состояние,стан
|
||||
starożytny,ancient,древний,давній
|
||||
statek,ship,корабль,корабель
|
||||
stawka,rate,ставка,ставка
|
||||
stopień,degree,степень,ступінь
|
||||
strach,fear,страх,страх
|
||||
straszny,terrible,ужасный,жахливий
|
||||
strategia,strategy,стратегия,стратегія
|
||||
stres,stress,стресс,стрес
|
||||
struktura,structure,структура,структура
|
||||
strzelać,to shoot,стрелять,стріляти
|
||||
suchy,dry,сухой,сухий
|
||||
sugerować,to suggest,предлагать,пропонувати
|
||||
święto,holiday,праздник,свято
|
||||
tablet,tablet,планшет,планшет
|
||||
tak,yes,да,так
|
||||
taki,such,такой,такий
|
||||
talerz,plate,тарелка,тарілка
|
||||
taniec,dance,танец,танець
|
||||
technologia,technology,технология,технологія
|
||||
telefon,phone,телефон,телефон
|
||||
telefonować,to call,звонить,телефонувати
|
||||
temperatura,temperature,температура,температура
|
||||
ten,this,этот,цей
|
||||
termin,deadline,срок,термін
|
||||
testować,to test,тестировать,тестувати
|
||||
tło,background,фон,тло
|
||||
tłum,crowd,толпа,натовп
|
||||
tłuszcz,fat,жир,жир
|
||||
tor,track,трасса,траса
|
||||
tradycja,tradition,традиция,традиція
|
||||
tradycyjny,traditional,традиционный,традиційний
|
||||
trampki,sneakers,кеды,кросівки
|
||||
transport,transport,транспорт,транспорт
|
||||
trasa,route,маршрут,маршрут
|
||||
trawa,grass,трава,трава
|
||||
trener,coach,тренер,тренер
|
||||
trenować,to train,тренироваться,тренуватися
|
||||
trochę,little,немного,трохи
|
||||
troska,concern,забота,турбота
|
||||
trząść,to shake,трясти,трясти
|
||||
trzeci,third,третий,третій
|
||||
trzymać,to hold,держать,тримати
|
||||
turystyka,tourism,туризм,туризм
|
||||
twój,your,твой,твій
|
||||
tylko,only,только,тільки
|
||||
typowy,typical,типичный,типовий
|
||||
ubierać,to dress,одевать,одягати
|
||||
uczenie,learning,обучение,навчання
|
||||
uczestniczyć,to participate,участвовать,брати участь
|
||||
uderzyć,to hit,ударить,вдарити
|
||||
układ,arrangement,расположение,розташування
|
||||
układać,to arrange,раскладывать,розкладати
|
||||
ukryć,to hide,спрятать,сховати
|
||||
ulubieniec,favorite,любимец,улюбленець
|
||||
umysł,mind,разум,розум
|
||||
unikać,to avoid,избегать,уникати
|
||||
upadek,fall,падение,падіння
|
||||
upuścić,to drop,уронить,упустити
|
||||
uraz,injury,травма,травма
|
||||
urządzenie,device,устройство,пристрій
|
||||
urzędnik,official,чиновник,чиновник
|
||||
usługa,service,услуга,послуга
|
||||
uśmiech,smile,улыбка,усмішка
|
||||
usunąć,to remove,удалить,видалити
|
||||
uświadomić,to realize,осознать,усвідомити
|
||||
uwaga,attention,внимание,увага
|
||||
użycie,use,использование,використання
|
||||
użytkownik,user,пользователь,користувач
|
||||
wąchać,to smell,нюхать,нюхати
|
||||
waga,weight,вес,вага
|
||||
walczyć,to fight,сражаться,боротися
|
||||
wąski,narrow,узкий,вузький
|
||||
wcale,at all,вовсе,зовсім
|
||||
wcześnie,early,рано,рано
|
||||
wczoraj,yesterday,вчера,вчора
|
||||
wędkarstwo,fishing,рыбалка,рибальство
|
||||
wejść,to enter,войти,увійти
|
||||
wiatr,wind,ветер,вітер
|
||||
widelec,fork,вилка,виделка
|
||||
widok,view,вид,краєвид
|
||||
więcej,more,больше,більше
|
||||
wiedza,knowledge,знание,знання
|
||||
wiek,age,возраст,вік
|
||||
wiele,many,много,багато
|
||||
wieś,village,деревня,село
|
||||
wieża,tower,башня,вежа
|
||||
więzienie,prison,тюрьма,в'язниця
|
||||
winda,elevator,лифт,ліфт
|
||||
wirus,virus,вирус,вірус
|
||||
witaj,hello,здравствуй,вітаю
|
||||
wizyta,visit,визит,візит
|
||||
władza,power,власть,влада
|
||||
właściciel,owner,владелец,власник
|
||||
właściwie,actually,собственно,власне
|
||||
własność,property,собственность,власність
|
||||
wliczony,included,включённый,включений
|
||||
wokoło,around,вокруг,навколо
|
||||
wolno,allowed,разрешено,дозволено
|
||||
wołowina,beef,говядина,яловичина
|
||||
wow,wow,вау,вау
|
||||
wpływać,to affect,влиять,впливати
|
||||
wschodni,eastern,восточный,східний
|
||||
wspierać,to support,поддерживать,підтримувати
|
||||
wspomnieć,to mention,упомянуть,згадати
|
||||
wśród,among,среди,серед
|
||||
wszędzie,everywhere,везде,скрізь
|
||||
wszystko,everything,всё,все
|
||||
wybierać,to choose,выбирать,обирати
|
||||
wybór,choice,выбор,вибір
|
||||
wybrzeże,coast,побережье,узбережжя
|
||||
wycieczka,trip,поездка,поїздка
|
||||
wygląd,appearance,внешность,зовнішність
|
||||
wygodny,comfortable,удобный,зручний
|
||||
wyjaśnienie,explanation,объяснение,пояснення
|
||||
wykład,lecture,лекция,лекція
|
||||
wykonać,to perform,выполнить,виконати
|
||||
wymawiać,to pronounce,произносить,вимовляти
|
||||
wynagrodzenie,salary,зарплата,заробітна плата
|
||||
wynalazek,invention,изобретение,винахід
|
||||
wynaleźć,to invent,изобрести,винайти
|
||||
wypadek,accident,авария,аварія
|
||||
wypisać,to write out,выписать,виписати
|
||||
wyraz,word,слово,слово
|
||||
wyrazić,to express,выразить,висловити
|
||||
wyraźnie,clearly,ясно,чітко
|
||||
wysłać,to send,отправить,надіслати
|
||||
wysoki,high,высокий,високий
|
||||
wysokość,height,высота,висота
|
||||
wystarczająco,enough,достаточно,достатньо
|
||||
wzdłuż,along,вдоль,уздовж
|
||||
wzgórze,hill,холм,пагорб
|
||||
wznosić,to raise,поднимать,піднімати
|
||||
wzór,pattern,узор,візерунок
|
||||
zaangażować,to involve,вовлечь,залучити
|
||||
zaatakować,to attack,атаковать,атакувати
|
||||
zabawka,toy,игрушка,іграшка
|
||||
zabawny,funny,смешной,смішний
|
||||
zachodni,western,западный,західний
|
||||
zachowanie,behavior,поведение,поведінка
|
||||
zadanie,task,задание,завдання
|
||||
żaden,none,никакой,жоден
|
||||
zadowolony,satisfied,довольный,задоволений
|
||||
zagraniczny,foreign,иностранный,іноземний
|
||||
zagubiony,lost,потерянный,загублений
|
||||
zakładać,to assume,предполагать,припускати
|
||||
zakończenie,ending,окончание,закінчення
|
||||
zakończyć,to finish,закончить,закінчити
|
||||
zakręt,turn,поворот,поворот
|
||||
zaleta,advantage,преимущество,перевага
|
||||
zależeć,to depend,зависеть,залежати
|
||||
zamiast,instead,вместо,замість
|
||||
zamknąć,to close,закрыть,закрити
|
||||
zamknięty,closed,закрытый,закритий
|
||||
zamówić,to order,заказать,замовити
|
||||
zanieczyszczenie,pollution,загрязнение,забруднення
|
||||
zapałka,match,спичка,сірник
|
||||
zaparkować,to park,припарковать,припаркувати
|
||||
zapewnić,to ensure,обеспечить,забезпечити
|
||||
zapisać,to save,сохранить,зберегти
|
||||
zapobiegać,to prevent,предотвращать,запобігати
|
||||
zaprosić,to invite,пригласить,запросити
|
||||
zaproszenie,invitation,приглашение,запрошення
|
||||
zarabiać,to earn,зарабатывать,заробляти
|
||||
zarezerwować,to book,забронировать,забронювати
|
||||
zarządzać,to manage,управлять,керувати
|
||||
zaskakujący,surprising,удивительный,дивовижний
|
||||
zastąpić,to replace,заменить,замінити
|
||||
zatłoczony,crowded,переполненный,переповнений
|
||||
zatrudniać,to employ,нанимать,наймати
|
||||
zauważyć,to notice,заметить,помітити
|
||||
zawierać,to contain,содержать,містити
|
||||
zbierać,to collect,собирать,збирати
|
||||
zdecydowanie,definitely,определённо,безумовно
|
||||
zdobyć,to gain,получить,здобути
|
||||
zdolność,ability,способность,здатність
|
||||
zdolny,able,способный,здатний
|
||||
zdziwiony,surprised,удивлённый,здивований
|
||||
zegarek,watch,часы,годинник
|
||||
żeglarstwo,sailing,парусный спорт,вітрильний спорт
|
||||
żeglować,to sail,плавать,плавати
|
||||
żeński,female,женский,жіночий
|
||||
zepsuty,broken,сломанный,зламаний
|
||||
zero,zero,ноль,нуль
|
||||
zgadywanie,guessing,угадывание,вгадування
|
||||
zidentyfikować,to identify,идентифицировать,ідентифікувати
|
||||
zielony,green,зелёный,зелений
|
||||
ziemia,earth / soil,земля / почва,земля / ґрунт
|
||||
zjednoczony,united,объединённый,об'єднаний
|
||||
złapać,to catch,поймать,спіймати
|
||||
źle,badly,плохо,погано
|
||||
złodziej,thief,вор,злодій
|
||||
złoto,gold,золото,золото
|
||||
zmartwiony,worried,обеспокоенный,стурбований
|
||||
zmiana,change,изменение,зміна
|
||||
zmniejszyć,to reduce,уменьшить,зменшити
|
||||
zmysł,sense,чувство,відчуття
|
||||
znaczek,stamp,марка,марка
|
||||
znak,sign,знак,знак
|
||||
zniknąć,to disappear,исчезнуть,зникнути
|
||||
zniszczyć,to destroy,уничтожить,знищити
|
||||
żołądek,stomach,желудок,шлунок
|
||||
żołnierz,soldier,солдат,солдат
|
||||
żółty,yellow,жёлтый,жовтий
|
||||
zranić,to hurt,ранить,поранити
|
||||
źródło,source,источник,джерело
|
||||
zrozumienie,understanding,понимание,розуміння
|
||||
związać,to tie,связать,зв'язати
|
||||
związek,relationship,отношения,стосунки
|
||||
zwiększyć,to increase,увеличить,збільшити
|
||||
zwycięzca,winner,победитель,переможець
|
||||
zwyczajny,ordinary,обычный,звичайний
|
||||
zwykły,usual,обычный,звичайний
|
||||
życzyć,to wish,желать,бажати
|
||||
żywy,alive,живой,живий
|
||||
|
+848
@@ -0,0 +1,848 @@
|
||||
polish,english,russian,ukrainian
|
||||
absolutnie,absolutely,абсолютно,абсолютно
|
||||
administracja,administration,администрация,адміністрація
|
||||
agent,agent,агент,агент
|
||||
akademicki,academic,академический,академічний
|
||||
aktualizować,to update,обновлять,оновлювати
|
||||
alarm,alarm,сигнализация,сигналізація
|
||||
albo,or,или,або
|
||||
album,album,альбом,альбом
|
||||
alternatywny,alternative,альтернативный,альтернативний
|
||||
ambicja,ambition,амбиция,амбіція
|
||||
analiza,analysis,анализ,аналіз
|
||||
ani,neither / nor,ни,ні
|
||||
aplikacja,application,приложение,додаток
|
||||
asortyment,assortment,ассортимент,асортимент
|
||||
asystent,assistant,ассистент,асистент
|
||||
atak,attack,атака,атака
|
||||
atmosfera,atmosphere,атмосфера,атмосфера
|
||||
atrakcja,attraction,аттракцион,атракціон
|
||||
automatycznie,automatically,автоматически,автоматично
|
||||
automatyczny,automatic,автоматический,автоматичний
|
||||
autorytet,authority,авторитет,авторитет
|
||||
autostrada,highway,автострада,автострада
|
||||
bąbel,bubble,пузырь,бульбашка
|
||||
badać,to research,исследовать,досліджувати
|
||||
bałagan,mess,беспорядок,безлад
|
||||
bardzo,very,очень,дуже
|
||||
bateria,battery,батарея,батарея
|
||||
bawełna,cotton,хлопок,бавовна
|
||||
bęben,drum,барабан,барабан
|
||||
bezpieczeństwo,safety,безопасность,безпека
|
||||
bezpośrednio,directly,непосредственно,безпосередньо
|
||||
bezrobocie,unemployment,безработица,безробіття
|
||||
bezrobotny,unemployed,безработный,безробітний
|
||||
bieda,poverty,бедность,бідність
|
||||
bielizna,underwear,нижнее бельё,нижня білизна
|
||||
bieżący,current,текущий,поточний
|
||||
bitwa,battle,битва,битва
|
||||
biwakować,to camp,ходить в поход,ходити в похід
|
||||
blady,pale,бледный,блідий
|
||||
blisko,near,близко,близько
|
||||
bliźniak,twin,близнец,близнюк
|
||||
blokować,to block,блокировать,блокувати
|
||||
błoto,mud,грязь,багнюка
|
||||
błyszczący,shiny,блестящий,блискучий
|
||||
bójka,fight,драка,бійка
|
||||
bolesny,painful,болезненный,болісний
|
||||
brakować,to lack,не хватать,бракувати
|
||||
brzeg,shore,берег,берег
|
||||
brzydki,ugly,некрасивый,негарний
|
||||
burza,storm,гроза,гроза
|
||||
całkowicie,completely,полностью,повністю
|
||||
całkowity,total,полный,повний
|
||||
całość,whole,целое,ціле
|
||||
całować,to kiss,целовать,цілувати
|
||||
cel,goal,цель,мета
|
||||
celować,to aim,целиться,цілитися
|
||||
cenny,valuable,ценный,цінний
|
||||
centralny,central,центральный,центральний
|
||||
centrować,to center,центрировать,центрувати
|
||||
centrum,center,центр,центр
|
||||
ceremonia,ceremony,церемония,церемонія
|
||||
chociaż,although,хотя,хоча
|
||||
ciągły,continuous,непрерывный,безперервний
|
||||
ciągnięcie,pulling,тяга,тягнення
|
||||
ciasny,tight,тесный,тісний
|
||||
cichy,quiet,тихий,тихий
|
||||
ciecz,liquid,жидкость,рідина
|
||||
cierpieć,to suffer,страдать,страждати
|
||||
ciśnienie,pressure,давление,тиск
|
||||
codziennie,daily,ежедневно,щоденно
|
||||
cokolwiek,anything,что угодно,що завгодно
|
||||
cytat,quote,цитата,цитата
|
||||
cytować,to quote,цитировать,цитувати
|
||||
czapka,cap,шапка,шапка
|
||||
często,often,часто,часто
|
||||
czy,whether,ли,чи
|
||||
czyj,whose,чей,чий
|
||||
czyn,deed,поступок,вчинок
|
||||
dalej,further,дальше,далі
|
||||
daleki,far,далёкий,далекий
|
||||
definicja,definition,определение,визначення
|
||||
definiować,to define,определять,визначати
|
||||
definitywny,definitive,окончательный,остаточний
|
||||
dekada,decade,десятилетие,десятиліття
|
||||
diament,diamond,алмаз,діамант
|
||||
długość,length,длина,довжина
|
||||
dno,bottom,дно,дно
|
||||
doceniać,to appreciate,ценить,цінувати
|
||||
dodanie,addition,добавление,додавання
|
||||
dodatkowy,additional,дополнительный,додатковий
|
||||
dokument,document,документ,документ
|
||||
dołączyć,to join,присоединиться,приєднатися
|
||||
dom,home,дом,дім
|
||||
doradzać,to advise,советовать,радити
|
||||
doskonale,perfectly,отлично,відмінно
|
||||
dostarczać,to deliver,доставлять,доставляти
|
||||
dostarczyć,to deliver,доставить,доставити
|
||||
dostęp,access,доступ,доступ
|
||||
doświadczenie,experience,опыт,досвід
|
||||
doświadczony,experienced,опытный,досвідчений
|
||||
dotyk,touch,прикосновение,дотик
|
||||
do tyłu,backwards,назад,назад
|
||||
drugi,second,второй,другий
|
||||
drugorzędny,secondary,второстепенный,другорядний
|
||||
druk,printing,печать,друк
|
||||
drżenie,trembling,дрожь,тремтіння
|
||||
duch,spirit,дух,дух
|
||||
dumny,proud,гордый,гордий
|
||||
dużo,many,много,багато
|
||||
dym,smoke,дым,дим
|
||||
dyrektor,director,директор,директор
|
||||
dżentelmen,gentleman,джентльмен,джентльмен
|
||||
działanie,action,действие,дія
|
||||
dzieciństwo,childhood,детство,дитинство
|
||||
dzielnica,district,район,район
|
||||
dziennik,diary,дневник,щоденник
|
||||
dziwny,strange,странный,дивний
|
||||
dzwon,bell,колокол,дзвін
|
||||
dzwonić,to call,звонить,телефонувати
|
||||
edukacyjny,educational,образовательный,освітній
|
||||
edukować,to educate,обучать,навчати
|
||||
efektywnie,effectively,эффективно,ефективно
|
||||
ekonomiczny,economic,экономический,економічний
|
||||
ekscytacja,excitement,волнение,хвилювання
|
||||
ekspert,expert,эксперт,експерт
|
||||
eksperymentować,to experiment,экспериментировать,експериментувати
|
||||
eksplodować,to explode,взрываться,вибухати
|
||||
eksplozja,explosion,взрыв,вибух
|
||||
eksport,export,экспорт,експорт
|
||||
elegancki,elegant,элегантный,елегантний
|
||||
element,element,элемент,елемент
|
||||
emeryt,retiree,пенсионер,пенсіонер
|
||||
emocja,emotion,эмоция,емоція
|
||||
etykieta,label,этикетка,етикетка
|
||||
ewentualnie,possibly,возможно,можливо
|
||||
fabuła,plot,сюжет,сюжет
|
||||
fascynujący,fascinating,захватывающий,захопливий
|
||||
federalny,federal,федеральный,федеральний
|
||||
filmować,to film,снимать,знімати
|
||||
finansowy,financial,финансовый,фінансовий
|
||||
flaga,flag,флаг,прапор
|
||||
fokus,focus,фокус,фокус
|
||||
fotograf,photographer,фотограф,фотограф
|
||||
fotografia,photography,фотография,фотографія
|
||||
funkcja,function,функция,функція
|
||||
futro,fur,мех,хутро
|
||||
gałąź,branch,ветка,гілка
|
||||
garaż,garage,гараж,гараж
|
||||
gardło,throat,горло,горло
|
||||
garnek,pot,кастрюля,каструля
|
||||
gdziekolwiek,anywhere,где угодно,де завгодно
|
||||
gdzieś,somewhere,где-то,десь
|
||||
gigantyczny,gigantic,гигантский,гігантський
|
||||
gładki,smooth,гладкий,гладкий
|
||||
gleba,soil,почва,ґрунт
|
||||
głęboki,deep,глубокий,глибокий
|
||||
globalny,global,глобальный,глобальний
|
||||
głośny,loud,громкий,голосний
|
||||
głosować,to vote,голосовать,голосувати
|
||||
głównie,mainly,в основном,в основному
|
||||
głupi,stupid,глупый,дурний
|
||||
gorzej,worse,хуже,гірше
|
||||
gospodarka,economy,экономика,економіка
|
||||
gospodarz,host,хозяин,господар
|
||||
granica,border,граница,кордон
|
||||
groza,horror,ужас,жах
|
||||
gryźć,to bite,кусать,кусати
|
||||
gwałtowny,violent,резкий,різкий
|
||||
gwiazda,star,звезда,зірка
|
||||
gwóźdź,nail,гвоздь,цвях
|
||||
handlować,to trade,торговать,торгувати
|
||||
historyczny,historical,исторический,історичний
|
||||
hojny,generous,щедрый,щедрий
|
||||
huragan,hurricane,ураган,ураган
|
||||
ich,their,их,їх
|
||||
igła,needle,игла,голка
|
||||
ignorować,to ignore,игнорировать,ігнорувати
|
||||
imigrant,immigrant,иммигрант,іммігрант
|
||||
imponujący,impressive,впечатляющий,вражаючий
|
||||
import,import,импорт,імпорт
|
||||
indywidualny,individual,индивидуальный,індивідуальний
|
||||
inteligencja,intelligence,интеллект,інтелект
|
||||
intencja,intention,намерение,намір
|
||||
inwestować,to invest,инвестировать,інвестувати
|
||||
inżynieria,engineering,инженерия,інженерія
|
||||
irytujący,irritating,раздражающий,дратівливий
|
||||
iść,to go,идти,йти
|
||||
jądrowy,nuclear,ядерный,ядерний
|
||||
jakikolwiek,any,любой,будь-який
|
||||
jasny,bright,светлый,світлий
|
||||
jeden,one,один,один
|
||||
język,language,язык,мова
|
||||
kabel,cable,кабель,кабель
|
||||
kampania,campaign,кампания,кампанія
|
||||
kanał,channel,канал,канал
|
||||
kandydat,candidate,кандидат,кандидат
|
||||
kapitan,captain,капитан,капітан
|
||||
kara,punishment,наказание,покарання
|
||||
kategoria,category,категория,категорія
|
||||
każdy,every,каждый,кожен
|
||||
kiedy,when,когда,коли
|
||||
kiedykolwiek,ever,когда-либо,коли-небудь
|
||||
kilka,several,несколько,кілька
|
||||
kłamstwo,lie,ложь,брехня
|
||||
klauzula,clause,пункт,пункт
|
||||
klawiatura,keyboard,клавиатура,клавіатура
|
||||
klient,client,клиент,клієнт
|
||||
kliknąć,to click,кликнуть,клацнути
|
||||
klucz,key,ключ,ключ
|
||||
kobieta,woman,женщина,жінка
|
||||
kolekcja,collection,коллекция,колекція
|
||||
kolorowy,colorful,цветной,кольоровий
|
||||
komentować,to comment,комментировать,коментувати
|
||||
komercyjny,commercial,коммерческий,комерційний
|
||||
komunikacja,communication,коммуникация,комунікація
|
||||
koncentrować,to concentrate,концентрировать,концентрувати
|
||||
koniecznie,necessarily,обязательно,обов'язково
|
||||
konkretnie,specifically,конкретно,конкретно
|
||||
konkurencyjny,competitive,конкурентоспособный,конкурентоспроможний
|
||||
konkurent,competitor,конкурент,конкурент
|
||||
konsekwencja,consequence,последствие,наслідок
|
||||
konsument,consumer,потребитель,споживач
|
||||
kontakt,contact,контакт,контакт
|
||||
konto,account,счёт,рахунок
|
||||
kontrast,contrast,контраст,контраст
|
||||
kontrola,control,контроль,контроль
|
||||
kopalnia,mine,шахта,шахта
|
||||
kopia,copy,копия,копія
|
||||
kopnąć,to kick,пнуть,копнути
|
||||
korzystać,to use,пользоваться,користуватися
|
||||
kostium,costume,костюм,костюм
|
||||
krawat,tie,галстук,краватка
|
||||
krawędź,edge,край,край
|
||||
krążyć,to circulate,циркулировать,циркулювати
|
||||
kremowy,creamy,кремовый,кремовий
|
||||
krewny,relative,родственник,родич
|
||||
królewski,royal,королевский,королівський
|
||||
kropla,drop,капля,крапля
|
||||
kryminalny,criminal,криминальный,кримінальний
|
||||
krzyk,scream,крик,крик
|
||||
krzyż,cross,крест,хрест
|
||||
ksiądz,priest,священник,священник
|
||||
książę,prince,князь,князь
|
||||
księżniczka,princess,принцесса,принцеса
|
||||
kukurydza,corn,кукуруза,кукурудза
|
||||
kulturalny,cultural,культурный,культурний
|
||||
kurz,dust,пыль,пил
|
||||
kwadrat,square,квадрат,квадрат
|
||||
kwalifikacja,qualification,квалификация,кваліфікація
|
||||
kwalifikować,to qualify,квалифицировать,кваліфікувати
|
||||
laboratorium,laboratory,лаборатория,лабораторія
|
||||
lać,to pour,лить,лити
|
||||
łączyć,to connect,соединять,з'єднувати
|
||||
łagodny,gentle,мягкий,м'який
|
||||
łańcuch,chain,цепь,ланцюг
|
||||
latający,flying,летающий,літаючий
|
||||
leczenie,treatment,лечение,лікування
|
||||
ledwie,barely,едва,ледве
|
||||
lepszy,better,лучший,кращий
|
||||
liczba,number,число,число
|
||||
limit,limit,лимит,ліміт
|
||||
lina,rope,верёвка,мотузка
|
||||
liść,leaf,лист,лист
|
||||
literatura,literature,литература,література
|
||||
lokalizacja,location,местоположение,місцезнаходження
|
||||
ludzie,people,люди,люди
|
||||
ludzki,human,человеческий,людський
|
||||
luksus,luxury,роскошь,розкіш
|
||||
machnąć,to wave,махнуть,махнути
|
||||
magia,magic,магия,магія
|
||||
mąka,flour,мука,борошно
|
||||
mało,little,мало,мало
|
||||
mały,small,маленький,малий
|
||||
małżeństwo,marriage,брак,шлюб
|
||||
marka,brand,бренд,бренд
|
||||
marketing,marketing,маркетинг,маркетинг
|
||||
marnować,to waste,тратить впустую,витрачати даремно
|
||||
martwić,to worry,беспокоить,хвилювати
|
||||
martwienie,worrying,беспокойство,хвилювання
|
||||
marynarz,sailor,моряк,моряк
|
||||
mentalny,mental,ментальный,ментальний
|
||||
mężczyzna,man,мужчина,чоловік
|
||||
miejscowy,local,местный,місцевий
|
||||
mierzyć,to measure,измерять,вимірювати
|
||||
mięsień,muscle,мышца,м'яз
|
||||
mieszać,to mix,мешать,змішувати
|
||||
mieszanka,mixture,смесь,суміш
|
||||
mistrz,master,мастер,майстер
|
||||
młodość,youth,молодость,молодість
|
||||
młody,young,молодой,молодий
|
||||
mniej,less,меньше,менше
|
||||
mocno,strongly,сильно,сильно
|
||||
modlić,to pray,молиться,молитися
|
||||
modlitwa,prayer,молитва,молитва
|
||||
modny,fashionable,модный,модний
|
||||
moneta,coin,монета,монета
|
||||
morderstwo,murder,убийство,вбивство
|
||||
mówiony,spoken,разговорный,розмовний
|
||||
muszla,shell,ракушка,мушля
|
||||
nagle,suddenly,вдруг,раптом
|
||||
nagłówek,headline,заголовок,заголовок
|
||||
nagranie,recording,запись,запис
|
||||
najbardziej,most,больше всего,найбільше
|
||||
najgorszy,worst,худший,найгірший
|
||||
najlepszy,best,лучший,найкращий
|
||||
najmniej,least,меньше всего,найменше
|
||||
najnowszy,newest,новейший,найновіший
|
||||
najwyższy,highest,высочайший,найвищий
|
||||
namiot,tent,палатка,намет
|
||||
napisany,written,написанный,написаний
|
||||
napiwek,tip,чаевые,чайові
|
||||
naprawa,repair,ремонт,ремонт
|
||||
naprzeciw,opposite,напротив,навпроти
|
||||
naród,nation,нация,нація
|
||||
narracja,narration,повествование,оповідь
|
||||
narta,ski,лыжа,лижа
|
||||
nasionko,seed,семечко,насіння
|
||||
nastawienie,attitude,настрой,налаштування
|
||||
następny,next,следующий,наступний
|
||||
nastrój,mood,настроение,настрій
|
||||
nasz,our,наш,наш
|
||||
naturalnie,naturally,естественно,природно
|
||||
natychmiastowy,immediate,немедленный,негайний
|
||||
naukowy,scientific,научный,науковий
|
||||
niedbały,negligent,небрежный,недбалий
|
||||
niedogodność,inconvenience,неудобство,незручність
|
||||
nielegalny,illegal,нелегальный,нелегальний
|
||||
nienawiść,hatred,ненависть,ненависть
|
||||
niepotrzebny,unnecessary,ненужный,непотрібний
|
||||
nieprzyjemny,unpleasant,неприятный,неприємний
|
||||
nieśmiały,shy,застенчивый,сором'язливий
|
||||
niesprawiedliwy,unfair,несправедливый,несправедливий
|
||||
niewiarygodnie,incredibly,невероятно,неймовірно
|
||||
niewinny,innocent,невинный,невинний
|
||||
niewolnik,slave,раб,раб
|
||||
niewygodny,uncomfortable,неудобный,незручний
|
||||
niezbędny,essential,необходимый,необхідний
|
||||
niezdolny,incapable,неспособный,нездатний
|
||||
niski,low,низкий,низький
|
||||
normalny,normal,нормальный,нормальний
|
||||
obcy,foreign,чужой,чужий
|
||||
obecnie,currently,в настоящее время,наразі
|
||||
obecny,present,настоящий,теперішній
|
||||
obietnica,promise,обещание,обіцянка
|
||||
objaw,symptom,симптом,симптом
|
||||
obok,next to,рядом,поруч
|
||||
obowiązek,duty,обязанность,обов'язок
|
||||
ocena,rating,оценка,оцінка
|
||||
oceniać,to rate,оценивать,оцінювати
|
||||
oczekiwany,expected,ожидаемый,очікуваний
|
||||
oczywiście,of course,конечно,звичайно
|
||||
oczywisty,obvious,очевидный,очевидний
|
||||
od,from,от,від
|
||||
odcinek,episode,эпизод,епізод
|
||||
oddech,breath,дыхание,дихання
|
||||
oddychać,to breathe,дышать,дихати
|
||||
oddychanie,breathing,дыхание,дихання
|
||||
oddzielić,to separate,отделить,відокремити
|
||||
odjazd,departure,отъезд,від'їзд
|
||||
odległy,distant,далёкий,далекий
|
||||
odpoczynek,rest,отдых,відпочинок
|
||||
odpowiedni,suitable,подходящий,відповідний
|
||||
odpowiedź,answer,ответ,відповідь
|
||||
odpowiedzialność,responsibility,ответственность,відповідальність
|
||||
odpowiedzialny,responsible,ответственный,відповідальний
|
||||
odrzucać,to reject,отклонять,відхиляти
|
||||
odważny,brave,смелый,сміливий
|
||||
odzwierciedlać,to reflect,отражать,відображати
|
||||
oferta,offer,предложение,пропозиція
|
||||
ofiara,victim,жертва,жертва
|
||||
ofiarować,to offer,предлагать,пропонувати
|
||||
oficjalny,official,официальный,офіційний
|
||||
oglądać,to watch,смотреть,дивитися
|
||||
ogłosić,to announce,объявить,оголосити
|
||||
ogłoszenie,announcement,объявление,оголошення
|
||||
ogólnoświatowy,worldwide,всемирный,всесвітній
|
||||
ogon,tail,хвост,хвіст
|
||||
ogrzewanie,heating,отопление,опалення
|
||||
okazja,opportunity,возможность,можливість
|
||||
okładka,cover,обложка,обкладинка
|
||||
około,about,около,близько
|
||||
określić,to define,определить,визначити
|
||||
okropny,terrible,ужасный,жахливий
|
||||
okrutny,cruel,жестокий,жорстокий
|
||||
ołów,lead,свинец,свинець
|
||||
opieka,care,забота,турбота
|
||||
opłata,fee,плата,плата
|
||||
opona,tire,шина,шина
|
||||
oprócz,except,кроме,крім
|
||||
oprogramowanie,software,программное обеспечение,програмне забезпечення
|
||||
opróżnić,to empty,опустошить,спорожнити
|
||||
organizator,organizer,организатор,організатор
|
||||
oryginał,original,оригинал,оригінал
|
||||
osiągnięcie,achievement,достижение,досягнення
|
||||
osobiście,personally,лично,особисто
|
||||
osobno,separately,отдельно,окремо
|
||||
ostry,sharp,острый,гострий
|
||||
ostrzegać,to warn,предупреждать,попереджати
|
||||
ostrzeżenie,warning,предупреждение,попередження
|
||||
oszukać,to cheat,обмануть,обдурити
|
||||
oszukiwać,to deceive,обманывать,обманювати
|
||||
paczka,package,посылка,посилка
|
||||
palec,finger,палец,палець
|
||||
paliwo,fuel,топливо,паливо
|
||||
paragon,receipt,чек,чек
|
||||
pasja,passion,страсть,пристрасть
|
||||
pasować,to fit,подходить,підходити
|
||||
patelnia,frying pan,сковорода,сковорода
|
||||
pchnięcie,push,толчок,поштовх
|
||||
personel,staff,персонал,персонал
|
||||
piasek,sand,песок,пісок
|
||||
piec,oven,печь,піч
|
||||
piękno,beauty,красота,краса
|
||||
pierwotnie,originally,изначально,спочатку
|
||||
pierwotny,original,первоначальный,первісний
|
||||
pierwszy,first,первый,перший
|
||||
pijany,drunk,пьяный,п'яний
|
||||
pikantny,spicy,острый,гострий
|
||||
pisać,to write,писать,писати
|
||||
plama,stain,пятно,пляма
|
||||
planowanie,planning,планирование,планування
|
||||
plasterek,slice,ломтик,скибка
|
||||
plastikowy,plastic,пластиковый,пластиковий
|
||||
płatność,payment,оплата,оплата
|
||||
płeć,gender,пол,стать
|
||||
plik,file,файл,файл
|
||||
płot,fence,забор,паркан
|
||||
plus,plus,плюс,плюс
|
||||
płynąć,to flow,плыть,плисти
|
||||
pływanie,swimming,плавание,плавання
|
||||
po,after,после,після
|
||||
pobieranie,downloading,скачивание,завантаження
|
||||
pochować,to bury,похоронить,поховати
|
||||
podać,to pass,подать,подати
|
||||
podatek,tax,налог,податок
|
||||
podczas,during,во время,під час
|
||||
podgrzewać,to heat,подогревать,підігрівати
|
||||
podkreślać,to emphasize,подчёркивать,підкреслювати
|
||||
podlewać,to water,поливать,поливати
|
||||
podobieństwo,similarity,сходство,схожість
|
||||
podobnie,similarly,похоже,схоже
|
||||
podobny,similar,похожий,схожий
|
||||
podpisać,to sign,подписать,підписати
|
||||
podróż,journey,путешествие,подорож
|
||||
podstawa,basis,основа,основа
|
||||
podstawowy,basic,основной,основний
|
||||
podsumować,to summarize,подытожить,підсумувати
|
||||
podwójnie,doubly,вдвойне,подвійно
|
||||
podwyżka,raise,повышение,підвищення
|
||||
podzielić,to divide,разделить,розділити
|
||||
podziemny,underground,подземный,підземний
|
||||
podziwiać,to admire,восхищаться,захоплюватися
|
||||
poeta,poet,поэт,поет
|
||||
poezja,poetry,поэзия,поезія
|
||||
pojedynczy,single,одиночный,одиничний
|
||||
pojemnik,container,контейнер,контейнер
|
||||
pokazać,to show,показать,показати
|
||||
pokierować,to direct,направить,спрямувати
|
||||
pokolenie,generation,поколение,покоління
|
||||
pokryty,covered,покрытый,покритий
|
||||
połączenie,connection,соединение,з'єднання
|
||||
połączyć,to connect,соединить,з'єднати
|
||||
polityczny,political,политический,політичний
|
||||
polityk,politician,политик,політик
|
||||
polityka,politics,политика,політика
|
||||
półka,shelf,полка,полиця
|
||||
północ,north,север,північ
|
||||
północny,northern,северный,північний
|
||||
połowa,half,половина,половина
|
||||
polować,to hunt,охотиться,полювати
|
||||
położony,located,расположенный,розташований
|
||||
położyć,to put,положить,покласти
|
||||
południe,south,юг,південь
|
||||
południowy,southern,южный,південний
|
||||
pomimo,despite,несмотря на,незважаючи на
|
||||
pomnik,monument,памятник,пам'ятник
|
||||
pomóc,to help,помочь,допомогти
|
||||
pop,pop,поп,поп
|
||||
poprawa,improvement,улучшение,покращення
|
||||
poprawnie,correctly,правильно,правильно
|
||||
poprzedni,previous,предыдущий,попередній
|
||||
porównanie,comparison,сравнение,порівняння
|
||||
port,port,порт,порт
|
||||
portret,portrait,портрет,портрет
|
||||
porządkowy,ordinal,порядковый,порядковий
|
||||
pośpiech,hurry,спешка,поспіх
|
||||
pośredni,indirect,косвенный,непрямий
|
||||
poszukiwanie,search,поиск,пошук
|
||||
potężny,powerful,могущественный,могутній
|
||||
potrzebować,to need,нуждаться,потребувати
|
||||
potwierdzić,to confirm,подтвердить,підтвердити
|
||||
poważnie,seriously,серьёзно,серйозно
|
||||
powiązany,related,связанный,пов'язаний
|
||||
powierzchnia,surface,поверхность,поверхня
|
||||
powiesić,to hang,повесить,повісити
|
||||
powinien,should,должен,повинен
|
||||
powtarzany,repeated,повторяемый,повторюваний
|
||||
powtórzenie,repetition,повторение,повторення
|
||||
poziomy,horizontal,горизонтальный,горизонтальний
|
||||
pozostać,to remain,остаться,залишитися
|
||||
praktyczny,practical,практичный,практичний
|
||||
prasa,press,пресса,преса
|
||||
prawda,truth,правда,правда
|
||||
prawidłowo,correctly,правильно,правильно
|
||||
prawny,legal,правовой,правовий
|
||||
prezentacja,presentation,презентация,презентація
|
||||
prezentować,to present,представлять,представляти
|
||||
próba,attempt,попытка,спроба
|
||||
próbka,sample,образец,зразок
|
||||
problem,problem,проблема,проблема
|
||||
procent,percent,процент,відсоток
|
||||
producent,producer,производитель,виробник
|
||||
produkcja,production,производство,виробництво
|
||||
prognoza,forecast,прогноз,прогноз
|
||||
programować,to program,программировать,програмувати
|
||||
promować,to promote,продвигать,просувати
|
||||
prosić,to ask,просить,просити
|
||||
prosty,simple,простой,простий
|
||||
proszek,powder,порошок,порошок
|
||||
protest,protest,протест,протест
|
||||
prywatny,private,частный,приватний
|
||||
przechowywać,to store,хранить,зберігати
|
||||
przed,before,перед,перед
|
||||
przedstawienie,performance,представление,вистава
|
||||
przekonać,to convince,убедить,переконати
|
||||
przełączyć,to switch,переключить,перемкнути
|
||||
przeprosić,to apologize,извиниться,вибачитися
|
||||
przerażający,terrifying,ужасающий,жахливий
|
||||
przestraszony,scared,испуганный,наляканий
|
||||
przestraszyć,to scare,испугать,налякати
|
||||
przeszkadzać,to disturb,мешать,заважати
|
||||
przeszły,past,прошлый,минулий
|
||||
przetłumaczyć,to translate,перевести,перекласти
|
||||
przewodnik,guide,гид,гід
|
||||
przez,through,через,через
|
||||
przeżyć,to survive,пережить,пережити
|
||||
przybycie,arrival,прибытие,прибуття
|
||||
przyciągać,to attract,привлекать,приваблювати
|
||||
przygotowany,prepared,подготовленный,підготовлений
|
||||
przyjaźń,friendship,дружба,дружба
|
||||
przyjemność,pleasure,удовольствие,задоволення
|
||||
przyjemny,pleasant,приятный,приємний
|
||||
przykleić,to stick,приклеить,приклеїти
|
||||
przypominać,to remind,напоминать,нагадувати
|
||||
przysługa,favor,услуга,послуга
|
||||
przyznać,to admit,признать,визнати
|
||||
pszczoła,bee,пчела,бджола
|
||||
publiczny,public,публичный,публічний
|
||||
pukanie,knocking,стук,стук
|
||||
pusty,empty,пустой,порожній
|
||||
ramka,frame,рамка,рамка
|
||||
ranny,wounded,раненый,поранений
|
||||
rasa,breed,порода,порода
|
||||
reakcja,reaction,реакция,реакція
|
||||
recenzja,review,обзор,огляд
|
||||
redaktor,editor,редактор,редактор
|
||||
referencja,reference,референция,референція
|
||||
regularnie,regularly,регулярно,регулярно
|
||||
rękawiczka,glove,перчатка,рукавичка
|
||||
relacja,relation,отношение,відношення
|
||||
relaksujący,relaxing,расслабляющий,розслаблюючий
|
||||
religia,religion,религия,релігія
|
||||
religijny,religious,религиозный,релігійний
|
||||
reprezentować,to represent,представлять,представляти
|
||||
rezerwacja,reservation,бронирование,бронювання
|
||||
robot,robot,робот,робот
|
||||
rodzimy,native,родной,рідний
|
||||
romantyczny,romantic,романтический,романтичний
|
||||
również,also,также,також
|
||||
równowaga,balance,равновесие,рівновага
|
||||
równy,equal,равный,рівний
|
||||
rozcięcie,cut,разрез,розріз
|
||||
rozczarowany,disappointed,разочарованный,розчарований
|
||||
rozczarowujący,disappointing,разочаровывающий,розчаровуючий
|
||||
rozdział,chapter,глава,розділ
|
||||
rozmowa,conversation,разговор,розмова
|
||||
różny,different,разный,різний
|
||||
rozprzestrzeniać,to spread,распространять,поширювати
|
||||
rozrywka,entertainment,развлечение,розвага
|
||||
rozsądny,reasonable,разумный,розумний
|
||||
rozszerzać,to expand,расширять,розширювати
|
||||
rozwój,development,развитие,розвиток
|
||||
ruch,movement,движение,рух
|
||||
rura,pipe,труба,труба
|
||||
ryzyko,risk,риск,ризик
|
||||
rząd,government,правительство,уряд
|
||||
rzadki,rare,редкий,рідкісний
|
||||
rzadko,rarely,редко,рідко
|
||||
rządzić,to rule,править,правити
|
||||
rzeczy,things,вещи,речі
|
||||
rzeczywiście,indeed,действительно,дійсно
|
||||
rzeczywistość,reality,реальность,реальність
|
||||
rzeźba,sculpture,скульптура,скульптура
|
||||
sąd,court,суд,суд
|
||||
sam,alone,один,сам
|
||||
samotny,lonely,одинокий,самотній
|
||||
scenariusz,script,сценарий,сценарій
|
||||
schłodzić,to cool,охладить,охолодити
|
||||
ścieżka,path,тропинка,стежка
|
||||
sekret,secret,секрет,секрет
|
||||
sen,dream,сон,сон
|
||||
serce,heart,сердце,серце
|
||||
sieć,network,сеть,мережа
|
||||
siła,strength,сила,сила
|
||||
skała,rock,скала,скеля
|
||||
skanować,to scan,сканировать,сканувати
|
||||
skarga,complaint,жалоба,скарга
|
||||
składać,to compose,составлять,складати
|
||||
skoczyć,to jump,прыгнуть,стрибнути
|
||||
skok,jump,прыжок,стрибок
|
||||
skóra,skin,кожа,шкіра
|
||||
skrzydło,wing,крыло,крило
|
||||
skuteczny,effective,эффективный,ефективний
|
||||
słodycz,sweetness,сладость,солодкість
|
||||
sługa,servant,слуга,слуга
|
||||
smakować,to taste,пробовать,куштувати
|
||||
smażyć,to fry,жарить,смажити
|
||||
śmigłowiec,helicopter,вертолёт,гелікоптер
|
||||
sortować,to sort,сортировать,сортувати
|
||||
spokój,calm,спокойствие,спокій
|
||||
spokojny,calm,спокойный,спокійний
|
||||
spotkanie,meeting,встреча,зустріч
|
||||
spowodować,to cause,вызвать,спричинити
|
||||
spowolnić,to slow down,замедлить,уповільнити
|
||||
spożywać,to consume,потреблять,споживати
|
||||
sprawiedliwie,fairly,справедливо,справедливо
|
||||
sprytny,clever,хитрый,хитрий
|
||||
srebrny,silver,серебряный,срібний
|
||||
średni,medium,средний,середній
|
||||
średnia,average,среднее,середнє
|
||||
środek,middle,середина,середина
|
||||
środowiskowy,environmental,экологический,екологічний
|
||||
stadion,stadium,стадион,стадіон
|
||||
stały,constant,постоянный,постійний
|
||||
standard,standard,стандарт,стандарт
|
||||
staromodny,old-fashioned,старомодный,старомодний
|
||||
statystyka,statistics,статистика,статистика
|
||||
strata,loss,потеря,втрата
|
||||
strażnik,guard,охранник,охоронець
|
||||
streszczenie,summary,резюме,резюме
|
||||
strzelać,to shoot,стрелять,стріляти
|
||||
studio,studio,студия,студія
|
||||
stwierdzić,to state,заявить,заявити
|
||||
substancja,substance,вещество,речовина
|
||||
sufit,ceiling,потолок,стеля
|
||||
suszyć,to dry,сушить,сушити
|
||||
świadomy,aware,осознанный,свідомий
|
||||
świecić,to shine,светить,світити
|
||||
święto,holiday,праздник,свято
|
||||
sygnalizować,to signal,сигнализировать,сигналізувати
|
||||
szacunek,respect,уважение,повага
|
||||
szafka,cabinet,шкафчик,шафка
|
||||
szalony,crazy,сумасшедший,божевільний
|
||||
szczególnie,especially,особенно,особливо
|
||||
szczęście,happiness,счастье,щастя
|
||||
szczotkować,to brush,чистить щёткой,чистити щіткою
|
||||
szkoda,pity,жаль,шкода
|
||||
sznurek,string,верёвка,мотузка
|
||||
szorstki,rough,шероховатый,шорсткий
|
||||
szpilka,pin,булавка,шпилька
|
||||
tajemnica,secret,тайна,таємниця
|
||||
tak,yes,да,так
|
||||
taki,such,такой,такий
|
||||
talent,talent,талант,талант
|
||||
tani,cheap,дешёвый,дешевий
|
||||
taśma,tape,лента,стрічка
|
||||
techniczny,technical,технический,технічний
|
||||
technika,technique,техника,техніка
|
||||
temat,topic,тема,тема
|
||||
teoria,theory,теория,теорія
|
||||
teraz,now,сейчас,зараз
|
||||
tkanina,fabric,ткань,тканина
|
||||
tłumaczenie,translation,перевод,переклад
|
||||
to,this,это,це
|
||||
toczyć,to roll,катить,котити
|
||||
tona,ton,тонна,тонна
|
||||
tonąć,to drown,тонуть,тонути
|
||||
tożsamość,identity,идентичность,ідентичність
|
||||
transportować,to transport,транспортировать,транспортувати
|
||||
trend,trend,тренд,тренд
|
||||
trener,coach,тренер,тренер
|
||||
trochę,little,немного,трохи
|
||||
troszczyć,to care,заботиться,піклуватися
|
||||
trucizna,poison,яд,отрута
|
||||
trudność,difficulty,трудность,трудність
|
||||
trujący,poisonous,ядовитый,отруйний
|
||||
trwać,to last,длиться,тривати
|
||||
twierdzić,to claim,утверждать,стверджувати
|
||||
typowo,typically,типично,типово
|
||||
ubrany,dressed,одетый,одягнений
|
||||
uciec,to escape,убежать,втекти
|
||||
uczciwy,honest,честный,чесний
|
||||
uczestniczyć,to participate,участвовать,брати участь
|
||||
udawać,to pretend,притворяться,прикидатися
|
||||
udowodnić,to prove,доказать,довести
|
||||
udział,participation,участие,участь
|
||||
ukarać,to punish,наказать,покарати
|
||||
ukończyć,to complete,закончить,закінчити
|
||||
umieścić,to place,разместить,розмістити
|
||||
umowa,contract,договор,договір
|
||||
uprzątnąć,to clean up,убрать,прибрати
|
||||
uśmiech,smile,улыбка,усмішка
|
||||
ustawienie,setting,настройка,налаштування
|
||||
utalentowany,talented,талантливый,талановитий
|
||||
uwolnić,to release,освободить,звільнити
|
||||
używany,used,подержанный,вживаний
|
||||
walka,fight,борьба,боротьба
|
||||
waluta,currency,валюта,валюта
|
||||
warga,lip,губа,губа
|
||||
warstwa,layer,слой,шар
|
||||
wartość,value,ценность,цінність
|
||||
wartościowy,valuable,ценный,цінний
|
||||
wątpliwość,doubt,сомнение,сумнів
|
||||
ważność,importance,важность,важливість
|
||||
ważyć,to weigh,взвешивать,зважувати
|
||||
wcześniej,earlier,раньше,раніше
|
||||
wdzięczny,grateful,благодарный,вдячний
|
||||
węgiel,coal,уголь,вугілля
|
||||
wejście,entrance,вход,вхід
|
||||
wełna,wool,шерсть,вовна
|
||||
wersja,version,версия,версія
|
||||
wesoły,cheerful,весёлый,веселий
|
||||
wewnątrz,inside,внутри,всередині
|
||||
wewnętrzny,internal,внутренний,внутрішній
|
||||
wiara,faith,вера,віра
|
||||
wiarygodny,credible,достоверный,достовірний
|
||||
wiązać,to tie,связывать,зв'язувати
|
||||
widok,view,вид,краєвид
|
||||
widz,viewer,зритель,глядач
|
||||
więcej,more,больше,більше
|
||||
wiersz,poem,стихотворение,вірш
|
||||
wieś,village,деревня,село
|
||||
więzień,prisoner,заключённый,в'язень
|
||||
winny,guilty,виновный,винний
|
||||
wiodący,leading,ведущий,провідний
|
||||
własność,property,собственность,власність
|
||||
własny,own,собственный,власний
|
||||
wokół,around,вокруг,навколо
|
||||
wola,will,воля,воля
|
||||
wolontariusz,volunteer,волонтёр,волонтер
|
||||
wpływ,influence,влияние,вплив
|
||||
wrażenie,impression,впечатление,враження
|
||||
wróg,enemy,враг,ворог
|
||||
wschód,east,восток,схід
|
||||
wschodni,eastern,восточный,східний
|
||||
wsiąść,to get on,сесть,сісти
|
||||
wskazać,to indicate,указать,вказати
|
||||
wskazówka,hint,подсказка,підказка
|
||||
wsparcie,support,поддержка,підтримка
|
||||
wspomnieć,to mention,упомянуть,згадати
|
||||
wszyscy,everyone,все,усі
|
||||
wybory,election,выборы,вибори
|
||||
wydatki,expenses,расходы,витрати
|
||||
wygodny,comfortable,удобный,зручний
|
||||
wyimaginowany,imaginary,воображаемый,уявний
|
||||
wyjście,exit,выход,вихід
|
||||
wykład,lecture,лекция,лекція
|
||||
wykształcony,educated,образованный,освічений
|
||||
wykwalifikowany,qualified,квалифицированный,кваліфікований
|
||||
wymagać,to require,требовать,вимагати
|
||||
wymiana,exchange,обмен,обмін
|
||||
wynająć,to rent,арендовать,орендувати
|
||||
wynik,result,результат,результат
|
||||
wyróżnić,to distinguish,выделить,виділити
|
||||
wyścigi,racing,гонки,перегони
|
||||
wysiłek,effort,усилие,зусилля
|
||||
wysłać,to send,отправить,надіслати
|
||||
wystąpić,to appear,выступить,виступити
|
||||
wystarczająco,enough,достаточно,достатньо
|
||||
wystawa,exhibition,выставка,виставка
|
||||
wyzwanie,challenge,вызов,виклик
|
||||
wzdłuż,along,вдоль,уздовж
|
||||
wzrost,growth,рост,зріст
|
||||
zaangażowany,engaged,вовлечённый,залучений
|
||||
zaawansowany,advanced,продвинутый,просунутий
|
||||
zabawiać,to entertain,развлекать,розважати
|
||||
zabawny,funny,смешной,смішний
|
||||
zabójstwo,murder,убийство,вбивство
|
||||
zachęcać,to encourage,поощрять,заохочувати
|
||||
zachód,west,запад,захід
|
||||
zachodni,western,западный,західний
|
||||
zadanie,task,задание,завдання
|
||||
zadowolony,satisfied,довольный,задоволений
|
||||
zafundować,to treat,угостить,пригостити
|
||||
żagiel,sail,парус,вітрило
|
||||
zakazać,to forbid,запретить,заборонити
|
||||
zakończenie,ending,окончание,закінчення
|
||||
zakończyć,to finish,закончить,закінчити
|
||||
zalewać,to flood,заливать,заливати
|
||||
zaliczenie,credit,зачёт,залік
|
||||
zamarznięty,frozen,замёрзший,замерзлий
|
||||
zamek,castle,замок,замок
|
||||
zamierzać,to intend,намереваться,мати намір
|
||||
zamknięty,closed,закрытый,закритий
|
||||
zamrozić,to freeze,заморозить,заморозити
|
||||
zapach,smell,запах,запах
|
||||
zapał,enthusiasm,энтузиазм,ентузіазм
|
||||
zapiąć,to fasten,застегнуть,застебнути
|
||||
żart,joke,шутка,жарт
|
||||
zarządzanie,management,управление,управління
|
||||
zaskoczyć,to surprise,удивить,здивувати
|
||||
zasłona,curtain,занавеска,завіса
|
||||
zasób,resource,ресурс,ресурс
|
||||
zatrudnić,to hire,нанять,найняти
|
||||
zatrudnienie,employment,трудоустройство,працевлаштування
|
||||
zauważyć,to notice,заметить,помітити
|
||||
zawartość,content,содержимое,вміст
|
||||
zawód,profession,профессия,професія
|
||||
zawstydzony,embarrassed,смущённый,збентежений
|
||||
zazwyczaj,usually,обычно,зазвичай
|
||||
zbierać,to collect,собирать,збирати
|
||||
zdecydowany,determined,решительный,рішучий
|
||||
zdezorientować,to disorient,дезориентировать,дезорієнтувати
|
||||
zdezorientowany,confused,растерянный,розгублений
|
||||
zdumiony,amazed,изумлённый,здивований
|
||||
żelazo,iron,железо,залізо
|
||||
żenujący,embarrassing,неловкий,ніяковий
|
||||
zestaw,set,набор,набір
|
||||
zginać,to bend,сгибать,згинати
|
||||
ziarno,grain,зерно,зерно
|
||||
ziemia,earth,земля,земля
|
||||
zirytowany,irritated,раздражённый,роздратований
|
||||
zlokalizować,to locate,локализовать,локалізувати
|
||||
złoto,gold,золото,золото
|
||||
złożony,complex,сложный,складний
|
||||
zły,bad,плохой,поганий
|
||||
zmartwiony,worried,обеспокоенный,стурбований
|
||||
zmiana,change,изменение,зміна
|
||||
znak,sign,знак,знак
|
||||
znany,known,известный,відомий
|
||||
zniżka,discount,скидка,знижка
|
||||
zobowiązać,to oblige,обязать,зобов'язати
|
||||
zorganizowany,organized,организованный,організований
|
||||
zranić,to hurt,ранить,поранити
|
||||
zraniony,injured,раненый,поранений
|
||||
zrelaksowany,relaxed,расслабленный,розслаблений
|
||||
zrewidować,to revise,пересмотреть,переглянути
|
||||
zrezygnować,to resign,отказаться,відмовитися
|
||||
związek,relationship,отношения,стосунки
|
||||
zwiedzać,to sightsee,осматривать,оглядати
|
||||
zwolennicy,supporters,сторонники,прихильники
|
||||
zwolennik,supporter,сторонник,прихильник
|
||||
zwycięstwo,victory,победа,перемога
|
||||
zwyczaj,custom,обычай,звичай
|
||||
życzenie,wish,пожелание,побажання
|
||||
życzliwy,kind,доброжелательный,доброзичливий
|
||||
zysk,profit,прибыль,прибуток
|
||||
|
Reference in New Issue
Block a user