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